返回 CodeWhale
executor.rs
根目录 / crates / tui / src / hooks / executor.rs
1 use super::{Hook, HookCondition, HookEvent, HooksConfig};
2 use chrono::{DateTime, Utc};
3 use serde_json::json;
4 use std::collections::HashMap;
5 use std::fmt;
6 use std::io::{Read, Write};
7 use std::path::PathBuf;
8 use std::process::{Child, Command, Stdio};
9 use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
10 use std::sync::{Arc, Mutex};
11 use std::thread::JoinHandle;
12 use std::time::{Duration, Instant};
13 use wait_timeout::ChildExt;
14
15 #[cfg(windows)]
16 use std::os::windows::io::AsRawHandle;
17 #[cfg(windows)]
18 use windows::Win32::Foundation::{CloseHandle, HANDLE};
19 #[cfg(windows)]
20 use windows::Win32::System::Diagnostics::ToolHelp::{
21 CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
22 };
23 #[cfg(windows)]
24 use windows::Win32::System::JobObjects::{
25 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
26 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
27 SetInformationJobObject, TerminateJobObject,
28 };
29 #[cfg(windows)]
30 use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
31 #[cfg(windows)]
32 use windows::core::PCWSTR;
33
34 /// Context passed to hooks via environment variables
35 #[derive(Debug, Clone, Default)]
36 pub struct HookContext {
37 /// Tool name (for ToolCallBefore/After)
38 pub tool_name: Option<String>,
39 /// Engine-assigned tool call id, so a `tool_call_before` record and the
40 /// matching `tool_call_after` / `on_error` record can be correlated.
41 pub tool_call_id: Option<String>,
42 /// Tool arguments as JSON string
43 pub tool_args: Option<String>,
44 /// Tool result output (truncated)
45 pub tool_result: Option<String>,
46 /// Tool exit code if applicable.
47 ///
48 /// `i64` end-to-end: a Windows crash code such as `3221225477`
49 /// (`0xC0000005`) is a real value `exec_shell` reports, and narrowing it
50 /// to `i32` used to discard exactly the failures a hook most wants to see.
51 pub tool_exit_code: Option<i64>,
52 /// Whether tool succeeded
53 pub tool_success: Option<bool>,
54 /// Current mode
55 pub mode: Option<String>,
56 /// Previous mode (for `ModeChange`)
57 pub previous_mode: Option<String>,
58 /// Session ID
59 pub session_id: Option<String>,
60 /// User message content
61 pub message: Option<String>,
62 /// Error message (for `OnError`)
63 pub error_message: Option<String>,
64 /// Workspace path
65 pub workspace: Option<PathBuf>,
66 /// Current model name
67 pub model: Option<String>,
68 /// Total tokens used
69 pub total_tokens: Option<u32>,
70 /// Session cost in USD
71 pub session_cost: Option<f64>,
72 }
73
74 impl HookContext {
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 pub fn with_tool_name(mut self, name: &str) -> Self {
80 self.tool_name = Some(name.to_string());
81 self
82 }
83
84 pub fn with_tool_call_id(mut self, id: &str) -> Self {
85 self.tool_call_id = Some(id.to_string());
86 self
87 }
88
89 pub fn with_tool_args(mut self, args: &serde_json::Value) -> Self {
90 self.tool_args = Some(truncate_env_value(
91 &args.to_string(),
92 HOOK_TOOL_ARGS_ENV_MAX_BYTES,
93 ));
94 self
95 }
96
97 pub fn with_tool_result(mut self, result: &str, success: bool, exit_code: Option<i64>) -> Self {
98 self.tool_result = Some(truncate_env_value(
99 result,
100 HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES,
101 ));
102 self.tool_success = Some(success);
103 self.tool_exit_code = exit_code;
104 self
105 }
106
107 pub fn with_mode(mut self, mode: &str) -> Self {
108 self.mode = Some(mode.to_string());
109 self
110 }
111
112 pub fn with_previous_mode(mut self, mode: &str) -> Self {
113 self.previous_mode = Some(mode.to_string());
114 self
115 }
116
117 pub fn with_workspace(mut self, path: PathBuf) -> Self {
118 self.workspace = Some(path);
119 self
120 }
121
122 pub fn with_model(mut self, model: &str) -> Self {
123 self.model = Some(model.to_string());
124 self
125 }
126
127 pub fn with_session_id(mut self, session_id: &str) -> Self {
128 self.session_id = Some(session_id.to_string());
129 self
130 }
131
132 pub fn with_message(mut self, message: &str) -> Self {
133 self.message = Some(message.to_string());
134 self
135 }
136
137 pub fn with_error(mut self, error: &str) -> Self {
138 self.error_message = Some(truncate_env_value(error, HOOK_ERROR_CONTEXT_MAX_BYTES));
139 self
140 }
141
142 pub fn with_tokens(mut self, tokens: u32) -> Self {
143 self.total_tokens = Some(tokens);
144 self
145 }
146
147 /// Clamp all observer-owned strings before the context is cloned into a
148 /// bounded queue. Builders already apply these limits, but fields remain
149 /// public for compatibility, so the submission boundary must defend
150 /// itself against a directly-constructed context too.
151 fn bounded_for_observer(mut self) -> Self {
152 fn bound(value: &mut Option<String>, max_bytes: usize) {
153 if let Some(raw) = value.take() {
154 *value = Some(truncate_env_value(&raw, max_bytes));
155 }
156 }
157
158 bound(&mut self.tool_name, HOOK_OBSERVER_METADATA_MAX_BYTES);
159 bound(&mut self.tool_call_id, HOOK_OBSERVER_METADATA_MAX_BYTES);
160 bound(&mut self.tool_args, HOOK_TOOL_ARGS_ENV_MAX_BYTES);
161 bound(&mut self.tool_result, HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES);
162 bound(&mut self.mode, HOOK_OBSERVER_METADATA_MAX_BYTES);
163 bound(&mut self.previous_mode, HOOK_OBSERVER_METADATA_MAX_BYTES);
164 bound(&mut self.session_id, HOOK_OBSERVER_METADATA_MAX_BYTES);
165 bound(&mut self.message, HOOK_MESSAGE_CONTEXT_MAX_BYTES);
166 bound(&mut self.error_message, HOOK_ERROR_CONTEXT_MAX_BYTES);
167 bound(&mut self.model, HOOK_OBSERVER_METADATA_MAX_BYTES);
168 if let Some(workspace) = self.workspace.take() {
169 self.workspace = Some(PathBuf::from(truncate_env_value(
170 &workspace.to_string_lossy(),
171 HOOK_OBSERVER_METADATA_MAX_BYTES,
172 )));
173 }
174 self
175 }
176
177 /// Convert to environment variables
178 pub fn to_env_vars(&self) -> HashMap<String, String> {
179 let mut env = HashMap::new();
180
181 if let Some(ref name) = self.tool_name {
182 env.insert("DEEPSEEK_TOOL_NAME".to_string(), name.clone());
183 }
184 if let Some(ref id) = self.tool_call_id {
185 env.insert("CODEWHALE_TOOL_CALL_ID".to_string(), id.clone());
186 env.insert("DEEPSEEK_TOOL_CALL_ID".to_string(), id.clone());
187 }
188 if let Some(ref args) = self.tool_args {
189 // Tool arguments can include whole patches or encoded payloads.
190 // Keep the diagnostic environment surface bounded just like tool
191 // results; hooks that need the canonical arguments already receive
192 // the structured tool request at the engine boundary.
193 env.insert(
194 "DEEPSEEK_TOOL_ARGS".to_string(),
195 truncate_env_value(args, HOOK_TOOL_ARGS_ENV_MAX_BYTES),
196 );
197 }
198 if let Some(ref result) = self.tool_result {
199 // Truncate result to 10KB to avoid environment variable size limits
200 env.insert(
201 "DEEPSEEK_TOOL_RESULT".to_string(),
202 truncate_env_value(result, 10000),
203 );
204 }
205 if let Some(code) = self.tool_exit_code {
206 env.insert("DEEPSEEK_TOOL_EXIT_CODE".to_string(), code.to_string());
207 }
208 if let Some(success) = self.tool_success {
209 env.insert("DEEPSEEK_TOOL_SUCCESS".to_string(), success.to_string());
210 }
211 if let Some(ref mode) = self.mode {
212 env.insert("DEEPSEEK_MODE".to_string(), mode.clone());
213 }
214 if let Some(ref prev) = self.previous_mode {
215 env.insert("DEEPSEEK_PREVIOUS_MODE".to_string(), prev.clone());
216 }
217 if let Some(ref session_id) = self.session_id {
218 env.insert("CODEWHALE_SESSION_ID".to_string(), session_id.clone());
219 env.insert("DEEPSEEK_SESSION_ID".to_string(), session_id.clone());
220 }
221 if let Some(ref message) = self.message {
222 // Truncate message to prevent env var issues
223 env.insert(
224 "DEEPSEEK_MESSAGE".to_string(),
225 truncate_env_value(message, 5000),
226 );
227 }
228 if let Some(ref error) = self.error_message {
229 // Bounded like every other payload field: a tool failure message
230 // can be the whole of a failed command's output, and an unbounded
231 // env var is both an exec limit risk and an accidental transcript
232 // copy in whatever the hook writes it to.
233 env.insert(
234 "DEEPSEEK_ERROR".to_string(),
235 truncate_env_value(error, 5000),
236 );
237 }
238 if let Some(ref ws) = self.workspace {
239 env.insert("DEEPSEEK_WORKSPACE".to_string(), ws.display().to_string());
240 }
241 if let Some(ref model) = self.model {
242 env.insert("DEEPSEEK_MODEL".to_string(), model.clone());
243 }
244 if let Some(tokens) = self.total_tokens {
245 env.insert("DEEPSEEK_TOTAL_TOKENS".to_string(), tokens.to_string());
246 }
247 if let Some(cost) = self.session_cost {
248 env.insert("DEEPSEEK_SESSION_COST".to_string(), format!("{cost:.6}"));
249 }
250
251 env
252 }
253 }
254
255 /// Clamp a hook environment value to `max_bytes`, on a UTF-8 boundary, with a
256 /// visible marker so a hook can tell truncation from a short value.
257 fn truncate_env_value(value: &str, max_bytes: usize) -> String {
258 if value.len() <= max_bytes {
259 return value.to_string();
260 }
261 let safe_end = value
262 .char_indices()
263 .take_while(|(i, c)| *i + c.len_utf8() <= max_bytes)
264 .last()
265 .map_or(0, |(i, c)| i + c.len_utf8());
266 format!("{}...[truncated]", &value[..safe_end])
267 }
268
269 /// Result of a hook execution
270 #[derive(Debug, Clone, Default)]
271 pub struct HookResult {
272 /// Hook name (if specified)
273 pub name: Option<String>,
274 /// Whether the hook succeeded.
275 ///
276 /// For a background hook this is `true` as soon as the bounded supervisor
277 /// accepts the job: no child outcome has been observed yet. Check
278 /// [`Self::background`] before reading this as "the command succeeded".
279 pub success: bool,
280 /// `true` when this result describes a background submission rather than
281 /// a completed run. Background results always carry `exit_code: None`,
282 /// empty `stdout`/`stderr`, and a duration that measures the spawn, not
283 /// the command.
284 pub background: bool,
285 /// `true` when the hook behind this result declared
286 /// `continue_on_error = false` and ran in the foreground.
287 ///
288 /// This travels with the *result*, not with the event, because it is the
289 /// only way a steering call site can tell "the gate that actually matched
290 /// this call could not answer" from "some other, unrelated strict hook for
291 /// the same event exists in config". Background submissions are never
292 /// strict: nothing is awaited, so there is no answer to withhold.
293 pub strict: bool,
294 /// Exit code from the hook command
295 pub exit_code: Option<i32>,
296 /// Standard output
297 pub stdout: String,
298 /// Standard error
299 #[allow(dead_code)] // written by prod constructors, read only in tests
300 pub stderr: String,
301 /// Time taken to execute
302 pub duration: Duration,
303 /// Error message if execution failed
304 pub error: Option<String>,
305 }
306
307 impl HookResult {
308 /// A result that carries an observed exit code, as opposed to a
309 /// background submission or a spawn failure.
310 ///
311 /// Steering paths must gate on this: a background hook's `exit_code` is
312 /// `None` because nothing was waited for, not because the command exited
313 /// without a code.
314 #[must_use]
315 pub fn observed_exit_code(&self) -> Option<i32> {
316 if self.background {
317 return None;
318 }
319 self.exit_code
320 }
321 }
322
323 /// Result of running mutable `message_submit` hooks.
324 #[derive(Debug, Clone, PartialEq, Eq)]
325 pub enum MessageSubmitOutcome {
326 /// No hook changed the submitted text.
327 Unchanged { warning: Option<String> },
328 /// One or more hooks replaced the submitted text.
329 Replaced {
330 text: String,
331 warning: Option<String>,
332 },
333 /// A hook intentionally blocked the submission.
334 Blocked { reason: String },
335 }
336
337 impl MessageSubmitOutcome {
338 pub fn unchanged() -> Self {
339 Self::Unchanged { warning: None }
340 }
341
342 pub fn replaced(text: String) -> Self {
343 Self::Replaced {
344 text,
345 warning: None,
346 }
347 }
348
349 fn with_warning(self, warning: Option<String>) -> Self {
350 match self {
351 Self::Unchanged { .. } => Self::Unchanged { warning },
352 Self::Replaced { text, .. } => Self::Replaced { text, warning },
353 Self::Blocked { reason } => Self::Blocked { reason },
354 }
355 }
356
357 pub fn warning(&self) -> Option<&str> {
358 match self {
359 Self::Unchanged { warning } | Self::Replaced { warning, .. } => warning.as_deref(),
360 Self::Blocked { .. } => None,
361 }
362 }
363 }
364
365 #[derive(Debug, Clone, PartialEq, Eq)]
366 enum MessageSubmitStdout {
367 Unchanged,
368 Replaced(String),
369 Invalid(String),
370 }
371
372 /// Maximum characters kept from one text field a `tool_call_before` hook
373 /// prints (`reason`, `additionalContext`).
374 ///
375 /// Both fields end up somewhere unbounded output would be a real problem:
376 /// `reason` in a TUI denial line, `additionalContext` inside the tool result
377 /// that is sent to the model and counted against the context budget. A hook
378 /// that prints a megabyte gets a bounded, marked prefix instead.
379 pub(crate) const HOOK_TEXT_FIELD_MAX_CHARS: usize = 2_000;
380
381 /// Maximum characters of concatenated `additionalContext` appended to a single
382 /// tool result, across every hook that contributed to that one call.
383 pub(crate) const HOOK_CONTEXT_AGGREGATE_MAX_CHARS: usize = 8_000;
384
385 /// Largest tool-argument snapshot exported through `DEEPSEEK_TOOL_ARGS`.
386 const HOOK_TOOL_ARGS_ENV_MAX_BYTES: usize = 10_000;
387
388 /// Largest raw tool result retained in an observer job before enqueue.
389 const HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES: usize = 10_000;
390
391 /// Largest error retained in an observer job before enqueue.
392 const HOOK_ERROR_CONTEXT_MAX_BYTES: usize = 5_000;
393
394 /// Largest user/message preview retained in an observer job before enqueue.
395 const HOOK_MESSAGE_CONTEXT_MAX_BYTES: usize = 5_000;
396
397 /// Largest identifier or other diagnostic retained in an observer job.
398 const HOOK_OBSERVER_METADATA_MAX_BYTES: usize = 4_096;
399
400 /// Largest stdout or stderr prefix retained from one foreground hook. Reader
401 /// threads continue draining after this cap so a verbose child cannot fill its
402 /// pipe and deadlock before exit; only the in-memory receipt is clipped.
403 const HOOK_PIPE_CAPTURE_MAX_BYTES: usize = 64 * 1024;
404
405 /// Largest serialized `updatedInput` object accepted from a decision hook.
406 /// This is intentionally smaller than the pipe cap so the surrounding JSON
407 /// and other fields still have headroom.
408 const HOOK_UPDATED_INPUT_MAX_BYTES: usize = 32 * 1024;
409
410 /// Largest replacement message accepted from `message_submit`.
411 const HOOK_MESSAGE_REPLACEMENT_MAX_CHARS: usize = 32_000;
412
413 /// Hard ceiling for the complete serialized `message_submit` stdin document.
414 /// The text prefix is fitted beneath this boundary after bounded metadata has
415 /// been added, so JSON escaping cannot push a producer past the limit.
416 pub(crate) const HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES: usize = 32 * 1024;
417
418 /// Individual metadata fields in `message_submit` stdin are diagnostic only.
419 /// Bound them before fitting text so an unusual workspace/model value cannot
420 /// consume the entire payload budget.
421 const HOOK_MESSAGE_SUBMIT_METADATA_MAX_BYTES: usize = 4 * 1024;
422
423 /// Largest turn error copied into a `turn_end` observer payload.
424 const HOOK_TURN_ERROR_MAX_CHARS: usize = 2_000;
425
426 /// Largest denial reason persisted into UI/model receipts.
427 const HOOK_DENIAL_RECEIPT_MAX_CHARS: usize = 240;
428
429 /// Bound and de-fang text a hook printed before it is shown or sent onward.
430 ///
431 /// Control characters are removed (`\r`) or flattened to a space so hook
432 /// stdout cannot repaint the TUI with escape sequences or forge structure in
433 /// the model-facing transcript; `\n` and `\t` survive because a hook's context
434 /// is legitimately multi-line. Truncation carries a visible marker so a
435 /// consumer can tell a clipped value from a short one.
436 pub(crate) fn sanitize_hook_text(text: &str, max_chars: usize) -> String {
437 let mut out = String::new();
438 let mut kept = 0usize;
439 let mut truncated = false;
440 for ch in text.chars() {
441 let mapped = match ch {
442 '\n' | '\t' => ch,
443 '\r' => continue,
444 c if c.is_control() => ' ',
445 c => c,
446 };
447 if kept == max_chars {
448 truncated = true;
449 break;
450 }
451 out.push(mapped);
452 kept += 1;
453 }
454 if truncated {
455 out.push_str("…[truncated]");
456 }
457 out
458 }
459
460 /// Longest hook/config name kept in a log line, a `/hooks` row, or a receipt.
461 ///
462 /// Names are operator-supplied and otherwise unbounded: nothing stops a
463 /// `name` from being a megabyte of ANSI escapes, and it is echoed into the
464 /// TUI, the tracing stream, and the model-facing denial.
465 pub(crate) const HOOK_LABEL_MAX_CHARS: usize = 64;
466
467 /// [`sanitize_hook_text`], forced onto one line.
468 ///
469 /// Labels and previews sit inside a formatted row, so an embedded newline or
470 /// tab would forge structure in the very listing that is supposed to describe
471 /// the hook. Everything else [`sanitize_hook_text`] does — control-character
472 /// removal and the marked truncation — still applies.
473 pub(crate) fn sanitize_hook_line(text: &str, max_chars: usize) -> String {
474 sanitize_hook_text(text, max_chars)
475 .chars()
476 .map(|c| if c == '\n' || c == '\t' { ' ' } else { c })
477 .collect()
478 }
479
480 /// The display label for a hook, from its optional operator-supplied `name`.
481 ///
482 /// One line, bounded, control-free, and never empty — every surface that
483 /// prints a hook name (logs, `/hooks list`, config problems, no-verdict
484 /// receipts) goes through here so there is one answer to "what can a `name`
485 /// put on my screen".
486 pub(crate) fn sanitize_hook_label(name: Option<&str>) -> String {
487 let cleaned = name
488 .map(|name| sanitize_hook_line(name, HOOK_LABEL_MAX_CHARS))
489 .unwrap_or_default();
490 if cleaned.trim().is_empty() {
491 "(unnamed)".to_string()
492 } else {
493 cleaned.trim().to_string()
494 }
495 }
496
497 #[derive(Clone, Copy)]
498 enum PendingDenialRedaction {
499 AuthorizationSchemeOrCredential,
500 SecretValue,
501 Command,
502 Path,
503 }
504
505 /// Split a denial into whitespace-delimited fields while keeping quoted
506 /// values together. This makes `command="rm -rf"` and
507 /// `path='/private folder'` one redaction unit even though the value contains
508 /// spaces. Unterminated quotes are conservatively kept in the final field.
509 fn denial_fields(line: &str) -> Vec<String> {
510 let mut fields = Vec::new();
511 let mut current = String::new();
512 let mut quote = None;
513 for ch in line.chars() {
514 match (quote, ch) {
515 (None, '\'' | '"') => {
516 quote = Some(ch);
517 current.push(ch);
518 }
519 (Some(open), close) if open == close => {
520 quote = None;
521 current.push(ch);
522 }
523 (None, ch) if ch.is_whitespace() => {
524 if !current.is_empty() {
525 fields.push(std::mem::take(&mut current));
526 }
527 }
528 _ => current.push(ch),
529 }
530 }
531 if !current.is_empty() {
532 fields.push(current);
533 }
534 fields
535 }
536
537 fn denial_field_core(field: &str) -> &str {
538 field.trim_matches(|ch: char| {
539 matches!(
540 ch,
541 '\'' | '"' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
542 )
543 })
544 }
545
546 fn denial_sensitive_assignment(field: &str) -> Option<(&str, &str)> {
547 let core = denial_field_core(field);
548 let separator = core.find([':', '='])?;
549 let key = denial_field_core(&core[..separator]);
550 let value = denial_field_core(&core[separator + 1..]);
551 Some((key, value))
552 }
553
554 fn normalized_denial_key(key: &str) -> String {
555 denial_field_core(key)
556 .chars()
557 .map(|ch| match ch {
558 '-' | '.' => '_',
559 ch => ch.to_ascii_lowercase(),
560 })
561 .collect()
562 }
563
564 fn denial_key_is_secret(key: &str) -> bool {
565 matches!(
566 key,
567 "token"
568 | "secret"
569 | "password"
570 | "passwd"
571 | "api_key"
572 | "apikey"
573 | "authorization"
574 | "bearer"
575 ) || key.ends_with("_api_key")
576 || key.ends_with("_token")
577 || key.ends_with("_secret")
578 }
579
580 /// Render an explicit hook denial without carrying raw process output into a
581 /// durable transcript. Structured reasons are useful operator copy, but they
582 /// still pass through a conservative redaction boundary: path-like tokens,
583 /// command-line flags, and common secret assignments are replaced rather than
584 /// persisted. Unstructured stdout/stderr never reaches this function.
585 pub(crate) fn sanitize_hook_denial_reason(reason: &str) -> String {
586 let line = sanitize_hook_line(reason, HOOK_DENIAL_RECEIPT_MAX_CHARS);
587 let mut redacted = Vec::new();
588 let mut pending = None;
589 for field in denial_fields(&line) {
590 let core = denial_field_core(&field);
591 let lower = core.to_ascii_lowercase();
592
593 if matches!(core, "=" | ":") {
594 continue;
595 }
596
597 if let Some(expected) = pending {
598 match expected {
599 PendingDenialRedaction::AuthorizationSchemeOrCredential => {
600 redacted.push("[secret]".to_string());
601 // Authorization uses `scheme credentials`. Treat the
602 // first field as a scheme even when it is proprietary;
603 // over-redacting one following field is safer than
604 // leaking a credential for a scheme we do not know.
605 pending = Some(PendingDenialRedaction::SecretValue);
606 }
607 PendingDenialRedaction::SecretValue => {
608 redacted.push("[secret]".to_string());
609 pending = None;
610 }
611 PendingDenialRedaction::Command => {
612 redacted.push("[command]".to_string());
613 pending = None;
614 }
615 PendingDenialRedaction::Path => {
616 redacted.push("[path]".to_string());
617 pending = None;
618 }
619 }
620 continue;
621 }
622
623 if let Some((key, value)) = denial_sensitive_assignment(&field) {
624 let key = normalized_denial_key(key);
625 if denial_key_is_secret(&key) {
626 redacted.push("[secret]".to_string());
627 pending = if key == "authorization" && value.is_empty() {
628 Some(PendingDenialRedaction::AuthorizationSchemeOrCredential)
629 } else if key == "authorization"
630 && !value.chars().any(char::is_whitespace)
631 && !value.contains(':')
632 {
633 // A lone assignment value is normally the scheme
634 // (`Authorization=Digest <credential>`). Quoted values
635 // containing whitespace already include both pieces.
636 Some(PendingDenialRedaction::SecretValue)
637 } else if value.is_empty() {
638 Some(PendingDenialRedaction::SecretValue)
639 } else {
640 None
641 };
642 continue;
643 }
644 if matches!(
645 key.as_str(),
646 "path" | "file" | "directory" | "cwd" | "workspace"
647 ) {
648 redacted.push("[path]".to_string());
649 pending = value.is_empty().then_some(PendingDenialRedaction::Path);
650 continue;
651 }
652 if matches!(key.as_str(), "command" | "cmd" | "argv" | "executable") {
653 redacted.push("[command]".to_string());
654 pending = value.is_empty().then_some(PendingDenialRedaction::Command);
655 continue;
656 }
657 }
658
659 let secret_prefix = lower.starts_with("sk-")
660 || lower.starts_with("ghp_")
661 || lower.starts_with("github_pat_");
662 let path_like = core.starts_with('/')
663 || core.starts_with("~/")
664 || core.starts_with("./")
665 || core.starts_with("../")
666 || core.contains('/')
667 || core.contains('\\')
668 || core
669 .as_bytes()
670 .get(1)
671 .is_some_and(|separator| *separator == b':');
672 let command_flag = core.starts_with('-');
673 let label = lower.trim_end_matches([':', '=']);
674 if matches!(label, "command" | "cmd" | "argv" | "executable") {
675 redacted.push("[command]".to_string());
676 pending = Some(PendingDenialRedaction::Command);
677 } else if label == "authorization" {
678 redacted.push("[secret]".to_string());
679 pending = Some(PendingDenialRedaction::AuthorizationSchemeOrCredential);
680 } else if matches!(label, "bearer" | "token" | "secret" | "password" | "passwd") {
681 redacted.push("[secret]".to_string());
682 pending = Some(PendingDenialRedaction::SecretValue);
683 } else if matches!(label, "path" | "file" | "directory" | "cwd" | "workspace") {
684 redacted.push("[path]".to_string());
685 pending = Some(PendingDenialRedaction::Path);
686 } else if secret_prefix {
687 redacted.push("[secret]".to_string());
688 } else if path_like {
689 redacted.push("[path]".to_string());
690 } else if command_flag {
691 redacted.push("[argument]".to_string());
692 } else {
693 redacted.push(field);
694 }
695 }
696 let rendered = sanitize_hook_line(&redacted.join(" "), HOOK_DENIAL_RECEIPT_MAX_CHARS);
697 if rendered.is_empty() {
698 "hook denied the action".to_string()
699 } else {
700 rendered
701 }
702 }
703
704 /// Render a foreground hook's failure as a detail string that is safe to show.
705 ///
706 /// The executor already writes generic errors, but this is the *boundary*, not
707 /// a restatement of that habit: only the shapes recognized here survive, and
708 /// each is re-rendered from parts rather than passed through. A future code
709 /// path that stuffs a command line, a resolved interpreter path, or hook
710 /// output into `HookResult::error` therefore cannot leak it into a receipt
711 /// merely by skipping the genericization at the producer — it degrades to the
712 /// catch-all instead, and the raw text is discarded.
713 pub(crate) fn generic_unavailable_detail(error: Option<&str>) -> String {
714 const GENERIC: &str = "hook returned no verdict";
715 let Some(error) = error else {
716 return GENERIC.to_string();
717 };
718 if let Some(rest) = error.strip_prefix("Hook timed out after ") {
719 let secs: String = rest.chars().take_while(char::is_ascii_digit).collect();
720 return if secs.is_empty() {
721 "hook timed out".to_string()
722 } else {
723 format!("hook timed out after {secs}s")
724 };
725 }
726 if let Some(rest) = error.strip_prefix("hook process could not be started (") {
727 // Only the `std::io::ErrorKind` debug name, and only if it really is
728 // one: bare ASCII letters, nothing else.
729 let kind: String = rest.chars().take_while(char::is_ascii_alphabetic).collect();
730 return if kind.is_empty() {
731 "hook process could not be started".to_string()
732 } else {
733 format!("hook process could not be started ({kind})")
734 };
735 }
736 if error.starts_with("failed to contain hook process tree")
737 || error.starts_with("failed to resume contained hook process")
738 {
739 return "hook process could not be contained".to_string();
740 }
741 if error.starts_with("hook executor did not run") {
742 return "hook executor did not run".to_string();
743 }
744 if error.starts_with("Failed to submit background hook")
745 || error.starts_with("background hook supervisor could not be started")
746 || error.starts_with("background hook supervisor queue is full")
747 || error.starts_with("background hook supervisor is unavailable")
748 {
749 return "hook could not be submitted".to_string();
750 }
751 if error.starts_with("Failed to wait for hook")
752 || error.starts_with("hook could not be reaped")
753 || error.starts_with("Failed to encode hook stdin")
754 || error.starts_with("hook stdout reader could not be started")
755 || error.starts_with("hook stderr reader could not be started")
756 || error.starts_with("hook stdin writer could not be started")
757 || error.starts_with("background hook process could not be started")
758 || error.starts_with("background hook stdin writer could not be started")
759 || error.starts_with("background hook setup")
760 {
761 return "hook did not complete cleanly".to_string();
762 }
763 tracing::debug!(target: "hooks", "hook failure had no recognized shape; reporting it generically");
764 GENERIC.to_string()
765 }
766
767 /// [`sanitize_hook_text`], dropping the value entirely when nothing
768 /// meaningful survives.
769 fn sanitized_hook_field(text: &str) -> Option<String> {
770 let cleaned = sanitize_hook_text(text, HOOK_TEXT_FIELD_MAX_CHARS);
771 if cleaned.trim().is_empty() {
772 None
773 } else {
774 Some(cleaned)
775 }
776 }
777
778 /// Parsed stdout from a `tool_call_before` hook (#3026).
779 ///
780 /// Hooks may emit a JSON decision on stdout:
781 /// `{"decision": "allow"|"deny"|"ask", "reason": "...",
782 /// "updatedInput": {...}, "additionalContext": "..."}`
783 /// Non-JSON or empty stdout → legacy passthrough (allow).
784 ///
785 /// `reason` and `additional_context` are sanitized and bounded here, at the
786 /// only door hook stdout comes through, so no downstream consumer has to
787 /// remember to do it.
788 #[derive(Debug, Clone, PartialEq, Eq)]
789 pub struct ToolCallBeforeStdout {
790 pub decision: Option<ToolCallDecision>,
791 pub reason: Option<String>,
792 pub updated_input: Option<serde_json::Value>,
793 pub additional_context: Option<String>,
794 }
795
796 /// Decision a hook can return for a tool call.
797 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
798 pub enum ToolCallDecision {
799 Allow,
800 Deny,
801 Ask,
802 }
803
804 pub(crate) fn parse_tool_call_before_stdout(stdout: &str) -> ToolCallBeforeStdout {
805 let passthrough = ToolCallBeforeStdout {
806 decision: None,
807 reason: None,
808 updated_input: None,
809 additional_context: None,
810 };
811 let trimmed = stdout.trim();
812 if trimmed.is_empty() {
813 return passthrough;
814 }
815 let value: serde_json::Value = match serde_json::from_str(trimmed) {
816 Ok(v) => v,
817 // Non-JSON stdout → legacy passthrough (allow).
818 Err(_) => return passthrough,
819 };
820 let Some(obj) = value.as_object() else {
821 tracing::warn!(
822 "tool_call_before hook stdout is JSON but not an object; \
823 ignoring it (legacy passthrough)"
824 );
825 return passthrough;
826 };
827 let decision = obj
828 .get("decision")
829 .and_then(|v| v.as_str())
830 .and_then(|s| match s {
831 "allow" => Some(ToolCallDecision::Allow),
832 "deny" => Some(ToolCallDecision::Deny),
833 "ask" => Some(ToolCallDecision::Ask),
834 _ => {
835 tracing::warn!(
836 "tool_call_before hook returned unrecognized decision \
837 (expected allow|deny|ask); treating as allow"
838 );
839 None
840 }
841 });
842 let reason = obj
843 .get("reason")
844 .and_then(|v| v.as_str())
845 .and_then(sanitized_hook_field);
846 let updated_input = obj.get("updatedInput").cloned().filter(|v| {
847 if !v.is_object() {
848 tracing::warn!("tool_call_before hook updatedInput must be a JSON object; ignoring");
849 return false;
850 }
851 let serialized_len = serde_json::to_vec(v).map_or(usize::MAX, |bytes| bytes.len());
852 if serialized_len > HOOK_UPDATED_INPUT_MAX_BYTES {
853 tracing::warn!(
854 serialized_len,
855 max_bytes = HOOK_UPDATED_INPUT_MAX_BYTES,
856 "tool_call_before hook updatedInput exceeded the size limit; ignoring"
857 );
858 return false;
859 }
860 true
861 });
862 let additional_context = obj
863 .get("additionalContext")
864 .and_then(|v| v.as_str())
865 .and_then(sanitized_hook_field);
866 ToolCallBeforeStdout {
867 decision,
868 reason,
869 updated_input,
870 additional_context,
871 }
872 }
873
874 /// Post-turn accumulated totals included in the `turn_end` observer payload.
875 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
876 pub struct TurnEndTotals {
877 pub session_tokens: u32,
878 pub conversation_tokens: u32,
879 pub input_tokens: u32,
880 pub output_tokens: u32,
881 }
882
883 /// Input used to build the structured `turn_end` observer payload.
884 pub struct TurnEndPayloadInput<'a> {
885 pub context: &'a HookContext,
886 pub created_at: DateTime<Utc>,
887 pub model_backed: bool,
888 pub provider: Option<&'a str>,
889 pub billing_surface: Option<&'a str>,
890 pub model: Option<&'a str>,
891 pub turn_id: &'a str,
892 pub status: &'a str,
893 pub error: Option<&'a str>,
894 pub duration: Duration,
895 pub usage: &'a codewhale_models::Usage,
896 pub totals: TurnEndTotals,
897 pub tool_count: usize,
898 pub queued_message_count: usize,
899 }
900
901 /// Owns the process tree created for one hook invocation.
902 ///
903 /// Hooks run through a shell, so killing only the immediate `sh`/`cmd.exe`
904 /// child can leave the actual hook runtime alive. Unix hooks get their own
905 /// process group and Windows hooks are attached to a kill-on-close Job Object.
906 /// Dropping this guard after the shell exits also closes inherited stdout and
907 /// stderr pipes held by any lingering descendants.
908 struct HookProcessTree {
909 #[cfg(unix)]
910 pgid: libc::pid_t,
911 #[cfg(windows)]
912 job: WindowsHookJob,
913 }
914
915 impl HookProcessTree {
916 fn attach(child: &Child) -> std::io::Result<Self> {
917 #[cfg(unix)]
918 {
919 Ok(Self {
920 pgid: child.id() as libc::pid_t,
921 })
922 }
923
924 #[cfg(windows)]
925 {
926 Ok(Self {
927 job: WindowsHookJob::attach(child)?,
928 })
929 }
930
931 #[cfg(not(any(unix, windows)))]
932 {
933 Ok(Self {})
934 }
935 }
936
937 fn terminate(&self, child: &mut Child) {
938 #[cfg(unix)]
939 {
940 // SAFETY: kill(2) dereferences no pointers.
941 let result = unsafe { libc::kill(-self.pgid, libc::SIGKILL) };
942 if result != 0 {
943 let error = std::io::Error::last_os_error();
944 if error.raw_os_error() != Some(libc::ESRCH) {
945 tracing::warn!(?error, "failed to terminate hook process group");
946 let _ = child.kill();
947 }
948 }
949 }
950
951 #[cfg(windows)]
952 {
953 let result = self
954 .job
955 .terminate()
956 .or_else(|_| kill_windows_process_tree(child.id()));
957 if let Err(error) = result {
958 tracing::warn!(
959 ?error,
960 "failed to terminate hook process tree; killing immediate child"
961 );
962 let _ = child.kill();
963 }
964 }
965
966 #[cfg(not(any(unix, windows)))]
967 {
968 let _ = child.kill();
969 }
970 }
971 }
972
973 impl Drop for HookProcessTree {
974 fn drop(&mut self) {
975 #[cfg(unix)]
976 // SAFETY: kill(2) dereferences no pointers.
977 unsafe {
978 // The shell may have exited while one of its descendants still
979 // holds a captured pipe. Reaping the process group keeps hook
980 // lifetimes bounded and lets the reader threads finish.
981 let _ = libc::kill(-self.pgid, libc::SIGKILL);
982 }
983 // On Windows, dropping WindowsHookJob closes a Job Object configured
984 // with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
985 }
986 }
987
988 #[cfg(windows)]
989 struct WindowsHookJob {
990 handle: HANDLE,
991 }
992
993 #[cfg(windows)]
994 impl WindowsHookJob {
995 fn attach(child: &Child) -> std::io::Result<Self> {
996 // SAFETY: returned handle is owned by the new wrapper.
997 let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? };
998 let job = Self { handle };
999 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1000 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1001
1002 // SAFETY: `limits` is live with matching size; both handles are live.
1003 unsafe {
1004 SetInformationJobObject(
1005 job.handle,
1006 JobObjectExtendedLimitInformation,
1007 &limits as *const _ as *const core::ffi::c_void,
1008 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
1009 )
1010 .map_err(windows_io_error)?;
1011 AssignProcessToJobObject(job.handle, HANDLE(child.as_raw_handle()))
1012 .map_err(windows_io_error)?;
1013 }
1014 Ok(job)
1015 }
1016
1017 fn terminate(&self) -> std::io::Result<()> {
1018 // SAFETY: `self.handle` is a live owned job handle.
1019 unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) }
1020 }
1021 }
1022
1023 #[cfg(windows)]
1024 impl Drop for WindowsHookJob {
1025 fn drop(&mut self) {
1026 // SAFETY: `self.handle` is owned here; Drop runs once.
1027 unsafe {
1028 let _ = CloseHandle(self.handle);
1029 }
1030 }
1031 }
1032
1033 #[cfg(windows)]
1034 fn windows_io_error(error: windows::core::Error) -> std::io::Error {
1035 std::io::Error::other(error)
1036 }
1037
1038 #[cfg(windows)]
1039 fn resume_windows_process(child: &Child) -> std::io::Result<()> {
1040 let snapshot =
1041 // SAFETY: returned handle is owned here; closed before return.
1042 unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(windows_io_error)? };
1043 let result = (|| {
1044 let mut entry = THREADENTRY32 {
1045 dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
1046 ..Default::default()
1047 };
1048 // SAFETY: `entry` is live with dwSize initialized above.
1049 let mut next = unsafe { Thread32First(snapshot, &mut entry) };
1050 let mut resumed = 0usize;
1051 while next.is_ok() {
1052 if entry.th32OwnerProcessID == child.id() {
1053 // SAFETY: returned handle is owned here; closed below.
1054 let thread = unsafe {
1055 OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID)
1056 .map_err(windows_io_error)?
1057 };
1058 // SAFETY: `thread` is a live owned handle.
1059 let resume_result = unsafe { ResumeThread(thread) };
1060 // SAFETY: `thread` is owned here and not used after.
1061 let close_result = unsafe { CloseHandle(thread).map_err(windows_io_error) };
1062 if resume_result == u32::MAX {
1063 return Err(std::io::Error::last_os_error());
1064 }
1065 close_result?;
1066 resumed += 1;
1067 }
1068 // SAFETY: `entry` is live with dwSize initialized above.
1069 next = unsafe { Thread32Next(snapshot, &mut entry) };
1070 }
1071 if resumed == 0 {
1072 return Err(std::io::Error::other(
1073 "suspended hook process had no resumable thread",
1074 ));
1075 }
1076 Ok(())
1077 })();
1078 // SAFETY: `snapshot` is owned here and not used after.
1079 let close_result = unsafe { CloseHandle(snapshot).map_err(windows_io_error) };
1080 result?;
1081 close_result
1082 }
1083
1084 #[cfg(windows)]
1085 fn kill_windows_process_tree(pid: u32) -> std::io::Result<()> {
1086 let mut command = Command::new("taskkill");
1087 crate::utils::suppress_console_window(&mut command);
1088 let pid = pid.to_string();
1089 let mut child = command
1090 .args(["/F", "/T", "/PID", pid.as_str()])
1091 .stdin(Stdio::null())
1092 .stdout(Stdio::null())
1093 .stderr(Stdio::null())
1094 .spawn()?;
1095 let status = wait_for_helper_status(&mut child, WINDOWS_TASKKILL_TIMEOUT)?;
1096 if status.success() {
1097 Ok(())
1098 } else {
1099 Err(std::io::Error::other(format!(
1100 "taskkill exited with {status}"
1101 )))
1102 }
1103 }
1104
1105 #[cfg(any(windows, test))]
1106 fn wait_for_helper_status(
1107 child: &mut Child,
1108 timeout: Duration,
1109 ) -> std::io::Result<std::process::ExitStatus> {
1110 match child.wait_timeout(timeout)? {
1111 Some(status) => Ok(status),
1112 None => {
1113 let _ = kill_and_reap_immediate_child(child, HOOK_REAP_TIMEOUT);
1114 Err(std::io::Error::new(
1115 std::io::ErrorKind::TimedOut,
1116 "hook helper did not finish within its timeout",
1117 ))
1118 }
1119 }
1120 }
1121
1122 fn kill_and_reap_immediate_child(child: &mut Child, timeout: Duration) -> bool {
1123 let _ = child.kill();
1124 matches!(child.wait_timeout(timeout), Ok(Some(_)))
1125 }
1126
1127 /// Spawn a contained hook child.
1128 ///
1129 /// Errors returned here are deliberately free of the hook command, the
1130 /// resolved interpreter path, and the OS message: the caller turns them into a
1131 /// user-visible "hook could not answer" receipt, and on Windows a raw spawn
1132 /// error echoes the whole command line back. The detail is logged instead.
1133 fn spawn_hook_child(command: &mut Command) -> std::io::Result<(Child, HookProcessTree)> {
1134 let mut child = command.spawn()?;
1135 let process_tree = match HookProcessTree::attach(&child) {
1136 Ok(process_tree) => process_tree,
1137 Err(error) => {
1138 // Windows hooks are created suspended, so a containment failure
1139 // cannot race with a descendant spawn. Fail closed without ever
1140 // running the uncontained hook.
1141 let _ = kill_and_reap_immediate_child(&mut child, HOOK_REAP_TIMEOUT);
1142 tracing::warn!(target: "hooks", %error, "failed to contain hook process tree");
1143 return Err(std::io::Error::other("failed to contain hook process tree"));
1144 }
1145 };
1146
1147 #[cfg(windows)]
1148 if let Err(error) = resume_windows_process(&child) {
1149 let _ = terminate_and_reap(None, &mut child, process_tree);
1150 tracing::warn!(target: "hooks", %error, "failed to resume contained hook process");
1151 return Err(std::io::Error::other(
1152 "failed to resume contained hook process",
1153 ));
1154 }
1155
1156 Ok((child, process_tree))
1157 }
1158
1159 /// A spawn failure rendered without the command, the path, or the OS message.
1160 ///
1161 /// The error kind is the useful, non-identifying part (`NotFound`,
1162 /// `PermissionDenied`, …); everything else is logged, not surfaced.
1163 fn spawn_failure_message(error: &std::io::Error) -> String {
1164 format!("hook process could not be started ({:?})", error.kind())
1165 }
1166
1167 const OBSERVER_DISPATCH_QUEUE_CAPACITY: usize = 32;
1168 const OBSERVER_DISPATCH_WORKERS: usize = 2;
1169
1170 #[derive(Debug, Clone, Copy)]
1171 enum ObserverDispatchFailure {
1172 Full,
1173 Disconnected,
1174 }
1175
1176 /// Bounded, persistent submission path for observer-only events.
1177 ///
1178 /// The terminal loop never creates a thread per event. Two long-lived workers
1179 /// drain a fixed-capacity channel, and `try_send` makes saturation observable
1180 /// without ever parking the caller.
1181 #[derive(Clone)]
1182 struct ObserverDispatcher {
1183 sender: Option<SyncSender<ObserverJob>>,
1184 #[cfg(test)]
1185 held_receiver: Option<Arc<Mutex<Receiver<ObserverJob>>>>,
1186 }
1187
1188 impl fmt::Debug for ObserverDispatcher {
1189 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1190 formatter
1191 .debug_struct("ObserverDispatcher")
1192 .field("available", &self.sender.is_some())
1193 .finish_non_exhaustive()
1194 }
1195 }
1196
1197 impl ObserverDispatcher {
1198 fn new() -> Self {
1199 let (sender, receiver) = mpsc::sync_channel(OBSERVER_DISPATCH_QUEUE_CAPACITY);
1200 let receiver = Arc::new(Mutex::new(receiver));
1201
1202 for worker_index in 0..OBSERVER_DISPATCH_WORKERS {
1203 let worker_receiver = Arc::clone(&receiver);
1204 let spawned = std::thread::Builder::new()
1205 .name(format!("hook-observer-{worker_index}"))
1206 .spawn(move || observer_worker_loop(worker_receiver));
1207 if let Err(error) = spawned {
1208 tracing::warn!(
1209 target: "hooks",
1210 worker_index,
1211 error_kind = ?error.kind(),
1212 "failed to start observer hook dispatcher"
1213 );
1214 // Dropping the only sender disconnects any workers that did
1215 // start. A partially-created pool is not presented as healthy.
1216 drop(sender);
1217 return Self {
1218 sender: None,
1219 #[cfg(test)]
1220 held_receiver: None,
1221 };
1222 }
1223 }
1224
1225 Self {
1226 sender: Some(sender),
1227 #[cfg(test)]
1228 held_receiver: None,
1229 }
1230 }
1231
1232 fn submit(&self, event: HookEvent, job: ObserverJob) -> Result<(), String> {
1233 let Some(sender) = &self.sender else {
1234 return Err(observer_dispatch_failure_message(
1235 event,
1236 ObserverDispatchFailure::Disconnected,
1237 ));
1238 };
1239 match sender.try_send(job) {
1240 Ok(()) => Ok(()),
1241 Err(TrySendError::Full(_)) => Err(observer_dispatch_failure_message(
1242 event,
1243 ObserverDispatchFailure::Full,
1244 )),
1245 Err(TrySendError::Disconnected(_)) => Err(observer_dispatch_failure_message(
1246 event,
1247 ObserverDispatchFailure::Disconnected,
1248 )),
1249 }
1250 }
1251 }
1252
1253 fn observer_dispatch_failure_message(event: HookEvent, failure: ObserverDispatchFailure) -> String {
1254 match failure {
1255 ObserverDispatchFailure::Full => format!(
1256 "{} observer hook queue is full; event was not submitted",
1257 event.as_str()
1258 ),
1259 ObserverDispatchFailure::Disconnected => format!(
1260 "{} observer hook dispatcher is unavailable; event was not submitted",
1261 event.as_str()
1262 ),
1263 }
1264 }
1265
1266 enum ObserverJob {
1267 Environment {
1268 hooks: HookExecutor,
1269 event: HookEvent,
1270 context: HookContext,
1271 },
1272 Json {
1273 hooks: HookExecutor,
1274 event: HookEvent,
1275 context: HookContext,
1276 payload: serde_json::Value,
1277 },
1278 }
1279
1280 impl ObserverJob {
1281 fn run(self) {
1282 match self {
1283 Self::Environment {
1284 hooks,
1285 event,
1286 context,
1287 } => {
1288 let _ = hooks.execute(event, &context);
1289 }
1290 Self::Json {
1291 hooks,
1292 event,
1293 context,
1294 payload,
1295 } => {
1296 let _ = hooks.execute_json_observer(event, &context, &payload);
1297 }
1298 }
1299 }
1300 }
1301
1302 fn observer_worker_loop(receiver: Arc<Mutex<Receiver<ObserverJob>>>) {
1303 loop {
1304 let received = match receiver.lock() {
1305 Ok(receiver) => receiver.recv(),
1306 Err(_) => {
1307 tracing::warn!(target: "hooks", "observer hook dispatcher lock was poisoned");
1308 return;
1309 }
1310 };
1311 match received {
1312 Ok(job) => job.run(),
1313 Err(_) => return,
1314 }
1315 }
1316 }
1317
1318 const BACKGROUND_SUPERVISOR_QUEUE_CAPACITY: usize = 32;
1319 const BACKGROUND_SUPERVISOR_WORKERS: usize = 2;
1320
1321 #[derive(Debug, Clone, Copy)]
1322 enum BackgroundSupervisorFailure {
1323 Full,
1324 Disconnected,
1325 }
1326
1327 /// Bounded pool that owns background-child setup, timeout, tree kill, and
1328 /// reap. Observer workers enqueue here instead of creating one detached
1329 /// supervisor thread per invocation.
1330 #[derive(Clone)]
1331 struct BackgroundSupervisor {
1332 sender: Option<SyncSender<BackgroundHookJob>>,
1333 #[cfg(test)]
1334 held_receiver: Option<Arc<Mutex<Receiver<BackgroundHookJob>>>>,
1335 }
1336
1337 impl fmt::Debug for BackgroundSupervisor {
1338 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1339 formatter
1340 .debug_struct("BackgroundSupervisor")
1341 .field("available", &self.sender.is_some())
1342 .finish_non_exhaustive()
1343 }
1344 }
1345
1346 impl BackgroundSupervisor {
1347 fn new() -> Self {
1348 let (sender, receiver) = mpsc::sync_channel(BACKGROUND_SUPERVISOR_QUEUE_CAPACITY);
1349 let receiver = Arc::new(Mutex::new(receiver));
1350
1351 for worker_index in 0..BACKGROUND_SUPERVISOR_WORKERS {
1352 let worker_receiver = Arc::clone(&receiver);
1353 let spawned = std::thread::Builder::new()
1354 .name(format!("hook-supervisor-{worker_index}"))
1355 .spawn(move || background_supervisor_worker_loop(worker_receiver));
1356 if let Err(error) = spawned {
1357 tracing::warn!(
1358 target: "hooks",
1359 worker_index,
1360 error_kind = ?error.kind(),
1361 "failed to start background hook supervisor pool"
1362 );
1363 drop(sender);
1364 return Self {
1365 sender: None,
1366 #[cfg(test)]
1367 held_receiver: None,
1368 };
1369 }
1370 }
1371
1372 Self {
1373 sender: Some(sender),
1374 #[cfg(test)]
1375 held_receiver: None,
1376 }
1377 }
1378
1379 fn submit(&self, job: BackgroundHookJob) -> Result<(), BackgroundSupervisorFailure> {
1380 let Some(sender) = &self.sender else {
1381 return Err(BackgroundSupervisorFailure::Disconnected);
1382 };
1383 match sender.try_send(job) {
1384 Ok(()) => Ok(()),
1385 Err(TrySendError::Full(_)) => Err(BackgroundSupervisorFailure::Full),
1386 Err(TrySendError::Disconnected(_)) => Err(BackgroundSupervisorFailure::Disconnected),
1387 }
1388 }
1389 }
1390
1391 struct BackgroundHookJob {
1392 command: String,
1393 env: HashMap<String, String>,
1394 working_dir: PathBuf,
1395 stdin_bytes: Option<Vec<u8>>,
1396 label: String,
1397 timeout: Duration,
1398 plugin_authority: Option<crate::plugins::types::PluginAuthority>,
1399 project_authority: Option<super::authority::ProjectHookAuthority>,
1400 }
1401
1402 impl BackgroundHookJob {
1403 fn run(self) {
1404 let Self {
1405 command: command_text,
1406 env,
1407 working_dir,
1408 stdin_bytes,
1409 label,
1410 timeout,
1411 plugin_authority,
1412 project_authority,
1413 } = self;
1414 if let Err(error) = super::authority::verify_hook_authorities(
1415 plugin_authority.as_ref(),
1416 project_authority.as_ref(),
1417 ) {
1418 tracing::warn!(
1419 target: "hooks",
1420 hook = %label,
1421 error = %error,
1422 "denied queued hook after authority changed"
1423 );
1424 return;
1425 }
1426 let timeout_secs = timeout.as_secs();
1427 let mut command = HookExecutor::build_shell_command(&command_text);
1428 command
1429 .current_dir(&working_dir)
1430 .envs(&env)
1431 .stdout(Stdio::null())
1432 .stderr(Stdio::null())
1433 // Always pipe stdin so dropping it delivers EOF through shell
1434 // wrappers even when there is no structured payload.
1435 .stdin(Stdio::piped());
1436
1437 let (mut child, process_tree) = match spawn_hook_child(&mut command) {
1438 Ok(child) => child,
1439 Err(error) => {
1440 tracing::warn!(
1441 target: "hooks",
1442 hook = %label,
1443 error_kind = ?error.kind(),
1444 "failed to start background hook"
1445 );
1446 return;
1447 }
1448 };
1449
1450 let _stdin_writer = match (stdin_bytes, child.stdin.take()) {
1451 (Some(bytes), Some(stdin)) => match spawn_stdin_writer(stdin, bytes) {
1452 Ok(writer) => Some(writer),
1453 Err(error) => {
1454 tracing::warn!(
1455 target: "hooks",
1456 hook = %label,
1457 error_kind = ?error.kind(),
1458 "failed to start background hook stdin writer"
1459 );
1460 terminate_and_reap(Some(label.as_str()), &mut child, process_tree);
1461 return;
1462 }
1463 },
1464 _ => None,
1465 };
1466
1467 match child.wait_timeout(timeout) {
1468 Ok(Some(status)) => {
1469 if !status.success() {
1470 tracing::warn!(
1471 target: "hooks",
1472 hook = %label,
1473 exit_code = ?status.code(),
1474 "background hook exited non-zero"
1475 );
1476 }
1477 }
1478 Ok(None) => {
1479 let reaped = terminate_and_reap(Some(label.as_str()), &mut child, process_tree);
1480 tracing::warn!(
1481 target: "hooks",
1482 hook = %label,
1483 timeout_secs,
1484 reaped,
1485 "background hook timed out; process tree killed"
1486 );
1487 }
1488 Err(error) => {
1489 terminate_and_reap(Some(label.as_str()), &mut child, process_tree);
1490 tracing::warn!(
1491 target: "hooks",
1492 hook = %label,
1493 ?error,
1494 "failed to wait for background hook; process tree killed"
1495 );
1496 }
1497 }
1498 }
1499 }
1500
1501 fn background_supervisor_worker_loop(receiver: Arc<Mutex<Receiver<BackgroundHookJob>>>) {
1502 loop {
1503 let received = match receiver.lock() {
1504 Ok(receiver) => receiver.recv(),
1505 Err(_) => {
1506 tracing::warn!(target: "hooks", "background supervisor lock was poisoned");
1507 return;
1508 }
1509 };
1510 match received {
1511 Ok(job) => job.run(),
1512 Err(_) => return,
1513 }
1514 }
1515 }
1516
1517 /// Executor for running hooks
1518 #[derive(Debug, Clone)]
1519 pub struct HookExecutor {
1520 config: HooksConfig,
1521 default_working_dir: PathBuf,
1522 session_id: String,
1523 observer_dispatcher: ObserverDispatcher,
1524 background_supervisor: BackgroundSupervisor,
1525 #[cfg(test)]
1526 lose_message_submit_executor: bool,
1527 }
1528
1529 impl HookExecutor {
1530 fn build_shell_command(command: &str) -> Command {
1531 #[cfg(windows)]
1532 {
1533 use std::os::windows::process::CommandExt as _;
1534 let mut cmd = Command::new("cmd");
1535 const CREATE_SUSPENDED: u32 = 0x0000_0004;
1536 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
1537 cmd.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW);
1538 // raw_arg: cmd.exe does not parse the CRT-style \" escapes that
1539 // Command::arg would insert, so pass the command line verbatim.
1540 cmd.arg("/C").raw_arg(command);
1541 cmd
1542 }
1543 #[cfg(not(windows))]
1544 {
1545 let mut cmd = Command::new("sh");
1546 cmd.arg("-c").arg(command);
1547 #[cfg(unix)]
1548 {
1549 use std::os::unix::process::CommandExt as _;
1550 cmd.process_group(0);
1551 }
1552 cmd
1553 }
1554 }
1555
1556 /// Create a new `HookExecutor` with configuration.
1557 ///
1558 /// This mints the hook session identity for the whole TUI session. Call it
1559 /// **once per launch**; every later reload (workspace switch, trust
1560 /// onboarding) must go through [`Self::rebind`] so the id every hook has
1561 /// already seen stays valid. Regenerating it mid-session would break
1562 /// correlation for anything that grouped records by `CODEWHALE_SESSION_ID`
1563 /// or its `DEEPSEEK_SESSION_ID` compatibility alias.
1564 pub fn new(config: HooksConfig, default_working_dir: PathBuf) -> Self {
1565 // Generate a session ID
1566 let session_id = format!("sess_{}", &uuid::Uuid::new_v4().to_string()[..8]);
1567 Self {
1568 config,
1569 default_working_dir,
1570 session_id,
1571 observer_dispatcher: ObserverDispatcher::new(),
1572 background_supervisor: BackgroundSupervisor::new(),
1573 #[cfg(test)]
1574 lose_message_submit_executor: false,
1575 }
1576 }
1577
1578 /// Rebuild the executor with new configuration and working directory while
1579 /// preserving the session identity minted at launch.
1580 ///
1581 /// Used when the workspace changes or a trust decision makes project hooks
1582 /// eligible: the hook set may change, the session does not.
1583 #[must_use]
1584 pub fn rebind(&self, config: HooksConfig, default_working_dir: PathBuf) -> Self {
1585 Self {
1586 config,
1587 default_working_dir,
1588 session_id: self.session_id.clone(),
1589 observer_dispatcher: self.observer_dispatcher.clone(),
1590 background_supervisor: self.background_supervisor.clone(),
1591 #[cfg(test)]
1592 lose_message_submit_executor: self.lose_message_submit_executor,
1593 }
1594 }
1595
1596 /// Create a disabled `HookExecutor` (no hooks will run)
1597 #[cfg(test)]
1598 pub fn disabled() -> Self {
1599 Self {
1600 config: HooksConfig {
1601 enabled: false,
1602 ..Default::default()
1603 },
1604 default_working_dir: PathBuf::from("."),
1605 session_id: String::new(),
1606 observer_dispatcher: ObserverDispatcher::new(),
1607 background_supervisor: BackgroundSupervisor::new(),
1608 #[cfg(test)]
1609 lose_message_submit_executor: false,
1610 }
1611 }
1612
1613 /// Check if hooks are enabled
1614 #[cfg(test)]
1615 pub fn is_enabled(&self) -> bool {
1616 self.config.enabled
1617 }
1618
1619 /// Get the session ID
1620 /// Read-only access to the underlying configuration. Used by
1621 /// `/hooks` (#460 read-only MVP) so the user can list configured
1622 /// hooks without reaching for `cat ~/.deepseek/config.toml`.
1623 pub fn config(&self) -> &HooksConfig {
1624 &self.config
1625 }
1626
1627 pub fn session_id(&self) -> &str {
1628 &self.session_id
1629 }
1630
1631 /// Cheap pre-check: are there any enabled hooks for this event?
1632 /// Lets call sites avoid building a [`HookContext`] (which allocates
1633 /// for `workspace`, `model`, `session_id`, …) on every tool call
1634 /// when the user hasn't configured any hooks. The cost matters
1635 /// because `ToolCallBefore` / `ToolCallAfter` fire from
1636 /// `tool_routing.rs` on every tool dispatch (#455).
1637 #[must_use]
1638 pub fn has_hooks_for_event(&self, event: HookEvent) -> bool {
1639 self.config.enabled && self.config.hooks.iter().any(|h| h.event == event)
1640 }
1641
1642 /// Check if there are any background hooks configured for a specific event.
1643 ///
1644 /// Background hooks fire and forget — their `exit_code` is always `None`,
1645 /// so they cannot deny tool calls. This is a known limitation; the check
1646 /// is used to warn operators when a `ToolCallBefore` hook is configured
1647 /// as background but expects to block a tool.
1648 #[must_use]
1649 pub fn has_background_hooks_for_event(&self, event: HookEvent) -> bool {
1650 if !self.config.enabled {
1651 return false;
1652 }
1653 self.config
1654 .hooks
1655 .iter()
1656 .any(|h| h.event == event && h.background)
1657 }
1658
1659 /// Sanitized labels of the strict foreground gates that *would* run for
1660 /// this event and context.
1661 ///
1662 /// "Strict" is `continue_on_error = false` on a foreground hook: an
1663 /// operator instruction that the action must not proceed without this
1664 /// hook's answer. The caller collects these **before** dispatching the
1665 /// executor so it can still honor them if the execution itself is lost —
1666 /// a panicked or cancelled `spawn_blocking` returns no results at all, and
1667 /// an empty result set is indistinguishable from "every hook allowed it".
1668 ///
1669 /// Condition matching is the same predicate [`Self::execute`] uses, so
1670 /// this never names a hook that would not have run: a strict `write_file`
1671 /// gate has no say over an `exec_shell` call it never matched.
1672 #[must_use]
1673 pub fn matched_strict_gate_labels(
1674 &self,
1675 event: HookEvent,
1676 context: &HookContext,
1677 ) -> Vec<String> {
1678 if !self.config.enabled {
1679 return Vec::new();
1680 }
1681 self.config
1682 .hooks_for_event(event)
1683 .into_iter()
1684 .filter(|hook| {
1685 // A background hook is never awaited, so it is not a gate no
1686 // matter what `continue_on_error` says.
1687 let foreground = !hook.background || !hook.event.honors_background();
1688 foreground && !hook.continue_on_error && self.matches_condition(hook, context)
1689 })
1690 .map(|hook| sanitize_hook_label(hook.name.as_deref()))
1691 .collect()
1692 }
1693
1694 /// Run configured `message_submit` hooks as a mutable submit pipeline.
1695 ///
1696 /// This is deliberately separate from [`Self::execute`]: most hook events
1697 /// are observer-only, while `message_submit` has a narrow stdout JSON
1698 /// contract that can replace or block the submitted text.
1699 pub fn execute_message_submit_transform(
1700 &self,
1701 context: &HookContext,
1702 original_text: &str,
1703 ) -> MessageSubmitOutcome {
1704 if !self.config.enabled {
1705 return MessageSubmitOutcome::unchanged();
1706 }
1707
1708 let hooks = self.config.hooks_for_event(HookEvent::MessageSubmit);
1709 if hooks.is_empty() {
1710 return MessageSubmitOutcome::unchanged();
1711 }
1712
1713 let mut current_text = original_text.to_string();
1714 let mut warning = None;
1715
1716 for hook in hooks {
1717 let hook_context = context.clone().with_message(&current_text);
1718 if !self.matches_condition(hook, &hook_context) {
1719 continue;
1720 }
1721
1722 let env_vars = hook_context.to_env_vars();
1723 let payload = message_submit_payload(&hook_context, &current_text);
1724 if hook.background {
1725 // A background `message_submit` hook cannot steer, but it must
1726 // still receive the documented stdin payload — the contract is
1727 // the same JSON, only the steering is dropped.
1728 let submitted = self.execute_background_with_stdin(hook, &env_vars, &payload);
1729 // Submission itself can fail (thread spawn refused, payload not
1730 // encodable). Discarding that silently is the one outcome an
1731 // operator cannot debug: the hook is configured, nothing runs,
1732 // and nothing says so. Still non-blocking — the submit proceeds.
1733 if !submitted.success {
1734 tracing::warn!(
1735 target: "hooks",
1736 hook = %sanitize_hook_label(submitted.name.as_deref()),
1737 event = "message_submit",
1738 error = %generic_unavailable_detail(submitted.error.as_deref()),
1739 "background message_submit hook was not submitted; it will not run"
1740 );
1741 }
1742 continue;
1743 }
1744
1745 let result = self.execute_sync_with_stdin(hook, &env_vars, &payload);
1746
1747 if result.exit_code == Some(2) {
1748 return MessageSubmitOutcome::Blocked {
1749 reason: message_submit_block_reason(
1750 &result,
1751 "message_submit hook blocked submission",
1752 ),
1753 };
1754 }
1755
1756 if !result.success {
1757 let label = sanitize_hook_label(result.name.as_deref());
1758 tracing::warn!(
1759 target: "hooks",
1760 hook = %label,
1761 event = "message_submit",
1762 exit_code = ?result.exit_code,
1763 duration_ms = result.duration.as_millis() as u64,
1764 detail = %generic_unavailable_detail(result.error.as_deref()),
1765 "message_submit hook failed"
1766 );
1767
1768 if hook.continue_on_error {
1769 warning = message_submit_continue_warning(&result).or(warning);
1770 continue;
1771 }
1772
1773 return MessageSubmitOutcome::Blocked {
1774 reason: message_submit_block_reason(
1775 &result,
1776 "message_submit hook failed and blocked submission",
1777 ),
1778 };
1779 }
1780
1781 match parse_message_submit_stdout(&result.stdout) {
1782 MessageSubmitStdout::Unchanged => {}
1783 MessageSubmitStdout::Replaced(text) => {
1784 current_text = text;
1785 }
1786 MessageSubmitStdout::Invalid(reason) => {
1787 tracing::warn!(
1788 target: "hooks",
1789 hook = %sanitize_hook_label(result.name.as_deref()),
1790 event = "message_submit",
1791 reason = %reason,
1792 "ignored invalid message_submit hook stdout"
1793 );
1794 }
1795 }
1796 }
1797
1798 if current_text == original_text {
1799 MessageSubmitOutcome::unchanged().with_warning(warning)
1800 } else {
1801 MessageSubmitOutcome::replaced(current_text).with_warning(warning)
1802 }
1803 }
1804
1805 /// Dispatch-bound entry point for the mutable submit gate.
1806 ///
1807 /// Keeping this wrapper distinct gives the production dispatch path a
1808 /// deterministic test seam for a lost blocking task. Normal hook tests use
1809 /// [`Self::execute_message_submit_transform`] directly.
1810 pub(crate) fn execute_message_submit_transform_for_dispatch(
1811 &self,
1812 context: &HookContext,
1813 original_text: &str,
1814 ) -> MessageSubmitOutcome {
1815 #[cfg(test)]
1816 if self.lose_message_submit_executor {
1817 panic!("injected message_submit executor loss");
1818 }
1819 self.execute_message_submit_transform(context, original_text)
1820 }
1821
1822 #[cfg(test)]
1823 pub(crate) fn inject_message_submit_executor_loss_for_test(&mut self) {
1824 self.lose_message_submit_executor = true;
1825 }
1826
1827 #[cfg(test)]
1828 pub(crate) fn inject_observer_dispatch_full_for_test(&mut self) {
1829 let (sender, receiver) = mpsc::sync_channel(0);
1830 self.observer_dispatcher.sender = Some(sender);
1831 // Keep the receiver connected but deliberately leave no worker waiting
1832 // on it. The production `try_send` path therefore returns `Full`.
1833 self.observer_dispatcher.held_receiver = Some(Arc::new(Mutex::new(receiver)));
1834 }
1835
1836 #[cfg(test)]
1837 pub(crate) fn inject_observer_dispatch_disconnect_for_test(&mut self) {
1838 let (sender, receiver) = mpsc::sync_channel(1);
1839 drop(receiver);
1840 self.observer_dispatcher.sender = Some(sender);
1841 self.observer_dispatcher.held_receiver = None;
1842 }
1843
1844 #[cfg(test)]
1845 fn inject_background_supervisor_full_for_test(&mut self) {
1846 let (sender, receiver) = mpsc::sync_channel(0);
1847 self.background_supervisor.sender = Some(sender);
1848 self.background_supervisor.held_receiver = Some(Arc::new(Mutex::new(receiver)));
1849 }
1850
1851 /// Run every `ShellEnv` hook for this context and merge their stdout
1852 /// (`KEY=VALUE\n` lines) into a single env-var map. Used by the
1853 /// `exec_shell` tool to inject ephemeral credentials, per-skill PATH
1854 /// adjustments, etc. (#456). Failures don't abort the shell call —
1855 /// the hook simply contributes no vars and a `tracing::warn!` lands.
1856 ///
1857 /// Each successful hook's keys (NOT values) are written to the audit
1858 /// log so a session can be reconciled later without leaking the
1859 /// secret material itself.
1860 pub fn collect_shell_env(&self, context: &HookContext) -> HashMap<String, String> {
1861 let mut merged: HashMap<String, String> = HashMap::new();
1862 if !self.config.enabled {
1863 return merged;
1864 }
1865 let hooks = self.config.hooks_for_event(HookEvent::ShellEnv);
1866 if hooks.is_empty() {
1867 return merged;
1868 }
1869 let env_vars = context.to_env_vars();
1870 for hook in hooks {
1871 if !self.matches_condition(hook, context) {
1872 continue;
1873 }
1874 // ShellEnv hooks must be synchronous — their stdout is the contract.
1875 let result = self.execute_sync(hook, &env_vars);
1876 if !result.success {
1877 tracing::warn!(
1878 target: "hooks",
1879 hook = %sanitize_hook_label(result.name.as_deref()),
1880 event = "shell_env",
1881 exit_code = ?result.exit_code,
1882 detail = %generic_unavailable_detail(result.error.as_deref()),
1883 "shell_env hook failed; contributing no env vars"
1884 );
1885 continue;
1886 }
1887 let parsed = parse_env_lines(&result.stdout);
1888 if parsed.is_empty() {
1889 continue;
1890 }
1891 // Audit-log the *keys* — never the values.
1892 crate::audit::log_sensitive_event(
1893 "shell_env_hook",
1894 serde_json::json!({
1895 // Bounded and de-fanged like every other rendering of a
1896 // hook name: an audit record is read by a person, often
1897 // through `tail`, where a raw escape sequence still acts.
1898 "hook": sanitize_hook_label(result.name.as_deref()),
1899 "tool": context.tool_name,
1900 "keys": parsed.keys().cloned().collect::<Vec<_>>(),
1901 }),
1902 );
1903 // Later hooks override earlier ones. Documented behavior.
1904 merged.extend(parsed);
1905 }
1906 merged
1907 }
1908
1909 /// Execute all hooks for an event
1910 pub fn execute(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
1911 if !self.config.enabled {
1912 return Vec::new();
1913 }
1914
1915 let hooks = self.config.hooks_for_event(event);
1916 if hooks.is_empty() {
1917 // Fast path: no hooks for this event → skip the
1918 // `context.to_env_vars()` HashMap allocation. With
1919 // `tool_call_before` / `tool_call_after` firing per-tool
1920 // (#455) this allocation would otherwise happen on every
1921 // tool dispatch even for users with zero hooks configured.
1922 return Vec::new();
1923 }
1924 let env_vars = context.to_env_vars();
1925 let mut results = Vec::new();
1926
1927 for hook in hooks {
1928 if !self.matches_condition(hook, context) {
1929 continue;
1930 }
1931
1932 let result = if hook.background {
1933 self.execute_background(hook, &env_vars)
1934 } else {
1935 self.execute_sync(hook, &env_vars)
1936 };
1937
1938 // Log failures via tracing so operators tailing
1939 // `deepseek` with `RUST_LOG=warn` can see hook errors
1940 // without instrumenting each call site. Successful runs
1941 // log nothing (would be too noisy on per-tool events).
1942 if !result.success {
1943 let label = sanitize_hook_label(result.name.as_deref());
1944 tracing::warn!(
1945 target: "hooks",
1946 hook = %label,
1947 event = event.as_str(),
1948 exit_code = ?result.exit_code,
1949 duration_ms = result.duration.as_millis() as u64,
1950 detail = %generic_unavailable_detail(result.error.as_deref()),
1951 "hook failed"
1952 );
1953 }
1954
1955 let should_continue = result.success || hook.continue_on_error;
1956 results.push(result);
1957
1958 if !should_continue {
1959 break;
1960 }
1961 }
1962
1963 results
1964 }
1965
1966 /// Execute observer hooks with a structured JSON stdin payload.
1967 ///
1968 /// Unlike `message_submit`, stdout is deliberately ignored by callers:
1969 /// these hooks are lifecycle observers and cannot mutate or block the
1970 /// underlying action.
1971 pub fn execute_json_observer(
1972 &self,
1973 event: HookEvent,
1974 context: &HookContext,
1975 payload: &serde_json::Value,
1976 ) -> Vec<HookResult> {
1977 if !self.config.enabled {
1978 return Vec::new();
1979 }
1980
1981 let hooks = self.config.hooks_for_event(event);
1982 if hooks.is_empty() {
1983 return Vec::new();
1984 }
1985
1986 let env_vars = context.to_env_vars();
1987 let mut results = Vec::new();
1988 for hook in hooks {
1989 if !self.matches_condition(hook, context) {
1990 continue;
1991 }
1992
1993 let result = if hook.background {
1994 self.execute_background_with_stdin(hook, &env_vars, payload)
1995 } else {
1996 self.execute_sync_with_stdin(hook, &env_vars, payload)
1997 };
1998
1999 if !result.success {
2000 let label = sanitize_hook_label(result.name.as_deref());
2001 tracing::warn!(
2002 target: "hooks",
2003 hook = %label,
2004 event = event.as_str(),
2005 exit_code = ?result.exit_code,
2006 duration_ms = result.duration.as_millis() as u64,
2007 detail = %generic_unavailable_detail(result.error.as_deref()),
2008 "observer hook failed"
2009 );
2010 }
2011
2012 results.push(result);
2013 }
2014
2015 results
2016 }
2017
2018 /// Submit an observer event without waiting on foreground child processes
2019 /// from the caller's thread. The outer worker is fallible and the failure
2020 /// is returned to the UI; silently dropping a configured observer is not a
2021 /// truthful fire-and-forget contract.
2022 pub fn submit_observer(&self, event: HookEvent, context: HookContext) -> Result<(), String> {
2023 if !self.has_hooks_for_event(event) {
2024 return Ok(());
2025 }
2026 self.observer_dispatcher.submit(
2027 event,
2028 ObserverJob::Environment {
2029 hooks: self.clone(),
2030 event,
2031 context: context.bounded_for_observer(),
2032 },
2033 )
2034 }
2035
2036 /// Structured-payload counterpart to [`Self::submit_observer`].
2037 pub fn submit_json_observer(
2038 &self,
2039 event: HookEvent,
2040 context: HookContext,
2041 payload: serde_json::Value,
2042 ) -> Result<(), String> {
2043 if !self.has_hooks_for_event(event) {
2044 return Ok(());
2045 }
2046 self.observer_dispatcher.submit(
2047 event,
2048 ObserverJob::Json {
2049 hooks: self.clone(),
2050 event,
2051 context: context.bounded_for_observer(),
2052 payload,
2053 },
2054 )
2055 }
2056
2057 /// Check whether a tool name matches a condition pattern with `*` glob support.
2058 fn tool_name_matches_condition(tool_name: &str, pattern: &str) -> bool {
2059 if !pattern.contains('*') {
2060 return tool_name == pattern;
2061 }
2062 // #6208: the pattern is fixed by configuration while this runs once per
2063 // hook per tool-call/stop event, so compile it once and reuse it rather
2064 // than building a fresh `Regex` on every event.
2065 codewhale_execpolicy::matcher::compiled_glob(pattern)
2066 .is_some_and(|re| re.is_match(tool_name))
2067 }
2068
2069 /// Check if a hook's condition matches the context
2070 #[allow(clippy::only_used_in_recursion)]
2071 fn matches_condition(&self, hook: &Hook, context: &HookContext) -> bool {
2072 match &hook.condition {
2073 None | Some(HookCondition::Always) => true,
2074 Some(HookCondition::ToolName { name }) => {
2075 // #3026: Support `*` globs in tool_name conditions so
2076 // `mcp__*` matches all MCP tools. Exact names keep working.
2077 context
2078 .tool_name
2079 .as_ref()
2080 .is_some_and(|n| Self::tool_name_matches_condition(n, name))
2081 }
2082 Some(HookCondition::ToolCategory { category }) => {
2083 let tool_category = context
2084 .tool_name
2085 .as_deref()
2086 .map(|name| tool_category_for(name, context.tool_args.as_deref()));
2087 tool_category.is_some_and(|c| c == category.as_str())
2088 }
2089 Some(HookCondition::Mode { mode }) => context
2090 .mode
2091 .as_ref()
2092 .is_some_and(|m| m.eq_ignore_ascii_case(mode)),
2093 Some(HookCondition::ExitCode { code }) => context.tool_exit_code == Some(*code),
2094 Some(HookCondition::All { conditions }) => conditions.iter().all(|c| {
2095 self.matches_condition(
2096 &Hook {
2097 condition: Some(c.clone()),
2098 ..hook.clone()
2099 },
2100 context,
2101 )
2102 }),
2103 Some(HookCondition::Any { conditions }) => conditions.iter().any(|c| {
2104 self.matches_condition(
2105 &Hook {
2106 condition: Some(c.clone()),
2107 ..hook.clone()
2108 },
2109 context,
2110 )
2111 }),
2112 }
2113 }
2114
2115 /// Execute a hook synchronously
2116 fn execute_sync(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
2117 self.execute_sync_inner(hook, env_vars, None)
2118 }
2119
2120 /// Execute a hook synchronously with a structured JSON stdin payload.
2121 ///
2122 /// Used by mutable `message_submit` hooks. Existing observer hooks keep the
2123 /// stdin-less [`Self::execute_sync`] path so their behavior is unchanged.
2124 fn execute_sync_with_stdin(
2125 &self,
2126 hook: &Hook,
2127 env_vars: &HashMap<String, String>,
2128 stdin_json: &serde_json::Value,
2129 ) -> HookResult {
2130 self.execute_sync_inner(hook, env_vars, Some(stdin_json))
2131 }
2132
2133 fn execute_sync_inner(
2134 &self,
2135 hook: &Hook,
2136 env_vars: &HashMap<String, String>,
2137 stdin_json: Option<&serde_json::Value>,
2138 ) -> HookResult {
2139 let started = Instant::now();
2140 if let Err(reason) = super::authority::verify_hook_authorities(
2141 hook.plugin_authority.as_ref(),
2142 hook.project_authority.as_ref(),
2143 ) {
2144 return HookResult {
2145 name: hook.name.clone(),
2146 background: false,
2147 strict: !hook.continue_on_error,
2148 success: false,
2149 exit_code: None,
2150 stdout: String::new(),
2151 stderr: String::new(),
2152 duration: started.elapsed(),
2153 error: Some(format!("Hook authority was denied: {reason}")),
2154 };
2155 }
2156 let working_dir = self
2157 .config
2158 .working_dir
2159 .clone()
2160 .unwrap_or_else(|| self.default_working_dir.clone());
2161
2162 let timeout_secs = self.effective_timeout_secs(hook);
2163 let timeout = Duration::from_secs(timeout_secs);
2164 // This path always runs the hook in the foreground and awaits it, so
2165 // `continue_on_error = false` is a live "do not proceed without my
2166 // answer" for whichever call this result belongs to.
2167 let strict = !hook.continue_on_error;
2168
2169 let stdin_bytes = match stdin_json.map(serde_json::to_vec).transpose() {
2170 Ok(bytes) => bytes,
2171 Err(e) => {
2172 return HookResult {
2173 name: hook.name.clone(),
2174 background: false,
2175 strict,
2176 success: false,
2177 exit_code: None,
2178 stdout: String::new(),
2179 stderr: String::new(),
2180 duration: started.elapsed(),
2181 error: Some(format!("Failed to encode hook stdin: {e}")),
2182 };
2183 }
2184 };
2185
2186 let mut command = Self::build_shell_command(&hook.command);
2187 command
2188 .current_dir(&working_dir)
2189 .envs(env_vars)
2190 .stdout(Stdio::piped())
2191 .stderr(Stdio::piped())
2192 // A closed pipe is a portable EOF signal through shell layers.
2193 // Windows cmd/PowerShell can reopen console input when handed
2194 // NUL, so Stdio::null() is not sufficient when the parent test or
2195 // terminal still owns a live stdin handle.
2196 .stdin(Stdio::piped());
2197
2198 let (mut child, process_tree) = match spawn_hook_child(&mut command) {
2199 Ok(child) => child,
2200 Err(e) => {
2201 // Generic on purpose: this string reaches the deny receipt and
2202 // the TUI, and a spawn error can otherwise echo the resolved
2203 // command line or interpreter path back to the transcript.
2204 tracing::warn!(
2205 target: "hooks",
2206 hook = %sanitize_hook_label(hook.name.as_deref()),
2207 error = %e,
2208 "failed to start hook process"
2209 );
2210 return HookResult {
2211 name: hook.name.clone(),
2212 background: false,
2213 strict,
2214 success: false,
2215 exit_code: None,
2216 stdout: String::new(),
2217 stderr: String::new(),
2218 duration: started.elapsed(),
2219 error: Some(spawn_failure_message(&e)),
2220 };
2221 }
2222 };
2223
2224 let stdout_reader = match child
2225 .stdout
2226 .take()
2227 .map(|pipe| spawn_pipe_reader(pipe, "hook-stdout-reader"))
2228 .transpose()
2229 {
2230 Ok(reader) => reader,
2231 Err(error) => {
2232 tracing::warn!(
2233 target: "hooks",
2234 hook = %sanitize_hook_label(hook.name.as_deref()),
2235 error_kind = ?error.kind(),
2236 "failed to start hook stdout reader"
2237 );
2238 terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
2239 return HookResult {
2240 name: hook.name.clone(),
2241 background: false,
2242 strict,
2243 success: false,
2244 exit_code: None,
2245 stdout: String::new(),
2246 stderr: String::new(),
2247 duration: started.elapsed(),
2248 error: Some("hook stdout reader could not be started".to_string()),
2249 };
2250 }
2251 };
2252 let stderr_reader = match child
2253 .stderr
2254 .take()
2255 .map(|pipe| spawn_pipe_reader(pipe, "hook-stderr-reader"))
2256 .transpose()
2257 {
2258 Ok(reader) => reader,
2259 Err(error) => {
2260 tracing::warn!(
2261 target: "hooks",
2262 hook = %sanitize_hook_label(hook.name.as_deref()),
2263 error_kind = ?error.kind(),
2264 "failed to start hook stderr reader"
2265 );
2266 terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
2267 let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2268 return HookResult {
2269 name: hook.name.clone(),
2270 background: false,
2271 strict,
2272 success: false,
2273 exit_code: None,
2274 stdout: String::new(),
2275 stderr: String::new(),
2276 duration: started.elapsed(),
2277 error: Some("hook stderr reader could not be started".to_string()),
2278 };
2279 }
2280 };
2281 let _stdin_writer = match (stdin_bytes, child.stdin.take()) {
2282 (Some(bytes), Some(stdin)) => match spawn_stdin_writer(stdin, bytes) {
2283 Ok(writer) => Some(writer),
2284 Err(error) => {
2285 tracing::warn!(
2286 target: "hooks",
2287 hook = %sanitize_hook_label(hook.name.as_deref()),
2288 error_kind = ?error.kind(),
2289 "failed to start hook stdin writer"
2290 );
2291 terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
2292 let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2293 let _ = collect_reader(stderr_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2294 return HookResult {
2295 name: hook.name.clone(),
2296 background: false,
2297 strict,
2298 success: false,
2299 exit_code: None,
2300 stdout: String::new(),
2301 stderr: String::new(),
2302 duration: started.elapsed(),
2303 error: Some("hook stdin writer could not be started".to_string()),
2304 };
2305 }
2306 },
2307 _ => None,
2308 };
2309
2310 match child.wait_timeout(timeout) {
2311 Ok(Some(status)) => {
2312 drop(process_tree);
2313 HookResult {
2314 name: hook.name.clone(),
2315 background: false,
2316 strict,
2317 success: status.success(),
2318 exit_code: status.code(),
2319 stdout: collect_reader(stdout_reader, HOOK_PIPE_DRAIN_TIMEOUT),
2320 stderr: collect_reader(stderr_reader, HOOK_PIPE_DRAIN_TIMEOUT),
2321 duration: started.elapsed(),
2322 error: None,
2323 }
2324 }
2325 Ok(None) => {
2326 let reaped = terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
2327 let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2328 let _ = collect_reader(stderr_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2329 HookResult {
2330 name: hook.name.clone(),
2331 background: false,
2332 strict,
2333 success: false,
2334 exit_code: None,
2335 stdout: String::new(),
2336 stderr: String::new(),
2337 duration: started.elapsed(),
2338 error: Some(if reaped {
2339 format!("Hook timed out after {timeout_secs}s")
2340 } else {
2341 // The gate still did not answer, and now we also cannot
2342 // prove the process is gone. Say the weaker thing.
2343 "hook could not be reaped after its timeout".to_string()
2344 }),
2345 }
2346 }
2347 Err(e) => {
2348 tracing::warn!(
2349 target: "hooks",
2350 hook = %sanitize_hook_label(hook.name.as_deref()),
2351 error = %e,
2352 "failed to wait for hook process"
2353 );
2354 terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
2355 let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2356 let _ = collect_reader(stderr_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
2357 HookResult {
2358 name: hook.name.clone(),
2359 background: false,
2360 strict,
2361 success: false,
2362 exit_code: None,
2363 stdout: String::new(),
2364 stderr: String::new(),
2365 duration: started.elapsed(),
2366 // Generic on purpose, like the spawn path: an OS wait error
2367 // can name the child and reaches the deny receipt.
2368 error: Some("Failed to wait for hook".to_string()),
2369 }
2370 }
2371 }
2372 }
2373
2374 /// Execute a hook in the background (non-blocking)
2375 fn execute_background(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
2376 self.execute_background_inner(hook, env_vars, None)
2377 }
2378
2379 fn execute_background_with_stdin(
2380 &self,
2381 hook: &Hook,
2382 env_vars: &HashMap<String, String>,
2383 stdin_json: &serde_json::Value,
2384 ) -> HookResult {
2385 self.execute_background_inner(hook, env_vars, Some(stdin_json))
2386 }
2387
2388 fn execute_background_inner(
2389 &self,
2390 hook: &Hook,
2391 env_vars: &HashMap<String, String>,
2392 stdin_json: Option<&serde_json::Value>,
2393 ) -> HookResult {
2394 let started = Instant::now();
2395 if let Err(reason) = super::authority::verify_hook_authorities(
2396 hook.plugin_authority.as_ref(),
2397 hook.project_authority.as_ref(),
2398 ) {
2399 return HookResult {
2400 name: hook.name.clone(),
2401 background: true,
2402 strict: false,
2403 success: false,
2404 exit_code: None,
2405 stdout: String::new(),
2406 stderr: String::new(),
2407 duration: started.elapsed(),
2408 error: Some(format!("Hook authority was denied: {reason}")),
2409 };
2410 }
2411 let working_dir = self
2412 .config
2413 .working_dir
2414 .clone()
2415 .unwrap_or_else(|| self.default_working_dir.clone());
2416
2417 let stdin_bytes = match stdin_json.map(serde_json::to_vec).transpose() {
2418 Ok(bytes) => bytes,
2419 Err(e) => {
2420 return HookResult {
2421 name: hook.name.clone(),
2422 background: true,
2423 strict: false,
2424 success: false,
2425 exit_code: None,
2426 stdout: String::new(),
2427 stderr: String::new(),
2428 duration: started.elapsed(),
2429 error: Some(format!("Failed to encode hook stdin: {e}")),
2430 };
2431 }
2432 };
2433 let submission = self.background_supervisor.submit(BackgroundHookJob {
2434 command: hook.command.clone(),
2435 env: env_vars.clone(),
2436 working_dir,
2437 stdin_bytes,
2438 label: sanitize_hook_label(hook.name.as_deref()),
2439 timeout: Duration::from_secs(self.effective_timeout_secs(hook)),
2440 plugin_authority: hook.plugin_authority.clone(),
2441 project_authority: hook.project_authority.clone(),
2442 });
2443
2444 // The result describes the bounded submission, not the run: no caller
2445 // can mistake "queued" for "exited 0".
2446 HookResult {
2447 name: hook.name.clone(),
2448 background: true,
2449 strict: false,
2450 success: submission.is_ok(),
2451 exit_code: None,
2452 stdout: String::new(),
2453 stderr: String::new(),
2454 duration: started.elapsed(),
2455 error: submission.err().map(|failure| match failure {
2456 BackgroundSupervisorFailure::Full => {
2457 "background hook supervisor queue is full".to_string()
2458 }
2459 BackgroundSupervisorFailure::Disconnected => {
2460 "background hook supervisor is unavailable".to_string()
2461 }
2462 }),
2463 }
2464 }
2465
2466 /// The timeout actually applied to a hook, foreground or background.
2467 ///
2468 /// `[hooks].default_timeout_secs` *replaces* the per-hook value when set;
2469 /// that is the shipped behavior and is documented as such in
2470 /// `docs/HOOKS.md`.
2471 fn effective_timeout_secs(&self, hook: &Hook) -> u64 {
2472 self.config.effective_timeout_secs(hook)
2473 }
2474 }
2475
2476 /// Classify a tool call for `condition = { type = "tool_category", … }`.
2477 ///
2478 /// Categories are `shell`, `file_write`, `safe`, and `other`, as documented in
2479 /// `docs/HOOKS.md`. This must be kept in step with the names the registry
2480 /// actually registers: before 2026-08-04 the map knew only the retired
2481 /// `exec_shell`/`write_file`/`read_file` spellings, so EVERY live call fell
2482 /// through to `other` and a `tool_category` **deny** hook silently never
2483 /// fired — the exact failure `docs/HOOKS.md` warns about ("a deny gate the
2484 /// operator believes is armed").
2485 ///
2486 /// `File`, `Git`, and `Run` are multi-action, so the action decides the
2487 /// category: a `File` read is `safe` while a `File` write is `file_write`.
2488 /// An unparseable or absent argument blob is treated as the tool's most
2489 /// dangerous action, because a gate that cannot see the action must not
2490 /// assume the harmless one.
2491 fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
2492 let action = tool_args
2493 .and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
2494 .and_then(|value| {
2495 value
2496 .get("action")
2497 .and_then(serde_json::Value::as_str)
2498 .map(str::to_ascii_lowercase)
2499 });
2500
2501 match tool_name {
2502 // The shell surface. `exec_shell` is retired but kept here because
2503 // `shell.rs` still stamps it for the `shell_env` hook event.
2504 "bash" | "Bash" | "exec_shell" => "shell",
2505 // The lowercase primitives ship without an action envelope.
2506 "read" | "todo_write" => "safe",
2507 "write" | "edit" => "file_write",
2508 "File" | "file" => match action.as_deref() {
2509 Some("read" | "list" | "search_name" | "search_content") => "safe",
2510 // write/edit/patch, and the unknown-action case, are writes.
2511 _ => "file_write",
2512 },
2513 "apply_patch" => "file_write",
2514 "Git" | "git" => match action.as_deref() {
2515 // Every shipped Git action is read-only today; classify by action
2516 // anyway so adding a mutating one cannot silently inherit `safe`.
2517 Some("status" | "diff" | "log" | "show" | "blame" | "commit_plan") => "safe",
2518 _ => "other",
2519 },
2520 // `Run` executes test/verifier commands — closer to shell than safe.
2521 "Run" | "run" => "shell",
2522 _ => "other",
2523 }
2524 }
2525
2526 const HOOK_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
2527 const HOOK_PIPE_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250);
2528 /// How long the timeout path waits for the killed child to be reaped.
2529 ///
2530 /// The wait after a kill is *bounded* rather than unbounded: `child.wait()`
2531 /// blocks forever if the kill did not take (a `SIGKILL`-immune uninterruptible
2532 /// state on Unix, a `TerminateJobObject` that a protected process survived on
2533 /// Windows), and that turned "this hook has a 30s budget" into a hung turn.
2534 const HOOK_REAP_TIMEOUT: Duration = Duration::from_secs(2);
2535 #[cfg(windows)]
2536 const WINDOWS_TASKKILL_TIMEOUT: Duration = Duration::from_secs(2);
2537
2538 /// Kill the hook's process tree and wait, briefly, for the corpse.
2539 ///
2540 /// Termination is best-effort by nature — the OS owns whether a kill lands.
2541 /// What is guaranteed here is that *this* thread stops waiting: the
2542 /// containment guard is dropped first (which re-signals the Unix process group
2543 /// and closes the kill-on-close Windows Job Object), then the reap gets one
2544 /// bounded window. Returns `false` when the child could not be confirmed dead,
2545 /// so the caller can report the weaker claim instead of asserting cleanup.
2546 fn terminate_and_reap(
2547 hook_name: Option<&str>,
2548 child: &mut Child,
2549 process_tree: HookProcessTree,
2550 ) -> bool {
2551 process_tree.terminate(child);
2552 // Drop before the wait, not after: on Windows this closes the Job Object
2553 // and is itself a kill, and on Unix it re-signals the group. Waiting first
2554 // would delay the very thing meant to make the wait short.
2555 drop(process_tree);
2556 match child.wait_timeout(HOOK_REAP_TIMEOUT) {
2557 Ok(Some(_)) => true,
2558 Ok(None) => {
2559 tracing::warn!(
2560 target: "hooks",
2561 hook = %sanitize_hook_label(hook_name),
2562 reap_timeout_secs = HOOK_REAP_TIMEOUT.as_secs(),
2563 "hook process did not exit after its tree was killed; abandoning the reap"
2564 );
2565 false
2566 }
2567 Err(error) => {
2568 tracing::warn!(
2569 target: "hooks",
2570 hook = %sanitize_hook_label(hook_name),
2571 %error,
2572 "failed to reap killed hook process"
2573 );
2574 false
2575 }
2576 }
2577 }
2578
2579 fn spawn_pipe_reader(
2580 mut pipe: impl Read + Send + 'static,
2581 worker_name: &str,
2582 ) -> std::io::Result<Receiver<String>> {
2583 let (tx, rx) = mpsc::channel();
2584 std::thread::Builder::new()
2585 .name(worker_name.to_string())
2586 .spawn(move || {
2587 let mut retained = Vec::with_capacity(HOOK_PIPE_CAPTURE_MAX_BYTES.min(8 * 1024));
2588 let mut chunk = [0_u8; 8 * 1024];
2589 let mut truncated = false;
2590 loop {
2591 match pipe.read(&mut chunk) {
2592 Ok(0) => break,
2593 Ok(read) => {
2594 let remaining = HOOK_PIPE_CAPTURE_MAX_BYTES.saturating_sub(retained.len());
2595 let keep = remaining.min(read);
2596 retained.extend_from_slice(&chunk[..keep]);
2597 truncated |= keep < read;
2598 }
2599 Err(error) => {
2600 tracing::warn!(target: "hooks", %error, "failed while draining hook pipe");
2601 break;
2602 }
2603 }
2604 }
2605 let mut output = String::from_utf8_lossy(&retained).into_owned();
2606 if truncated {
2607 output.push_str("…[truncated]");
2608 }
2609 let _ = tx.send(output);
2610 })
2611 .map(|_| rx)
2612 }
2613
2614 fn collect_reader(reader: Option<Receiver<String>>, timeout: Duration) -> String {
2615 let Some(reader) = reader else {
2616 return String::new();
2617 };
2618 match reader.recv_timeout(timeout) {
2619 Ok(output) => output,
2620 Err(RecvTimeoutError::Timeout) => {
2621 tracing::warn!(
2622 ?timeout,
2623 "hook pipe reader did not finish after process cleanup"
2624 );
2625 String::new()
2626 }
2627 Err(RecvTimeoutError::Disconnected) => String::new(),
2628 }
2629 }
2630
2631 fn spawn_stdin_writer(
2632 mut stdin: std::process::ChildStdin,
2633 mut bytes: Vec<u8>,
2634 ) -> std::io::Result<JoinHandle<()>> {
2635 std::thread::Builder::new()
2636 .name("hook-stdin-writer".to_string())
2637 .spawn(move || {
2638 bytes.push(b'\n');
2639 let _ = stdin.write_all(&bytes);
2640 let _ = stdin.flush();
2641 })
2642 }
2643
2644 fn bounded_message_submit_metadata(value: Option<&str>, max_bytes: usize) -> Option<String> {
2645 value.map(|value| truncate_env_value(value, max_bytes))
2646 }
2647
2648 fn build_message_submit_payload(
2649 context: &HookContext,
2650 text: &str,
2651 original_bytes: usize,
2652 truncated: bool,
2653 metadata_max_bytes: Option<usize>,
2654 ) -> serde_json::Value {
2655 let mut payload = json!({
2656 "event": HookEvent::MessageSubmit.as_str(),
2657 "text": text,
2658 "text_bytes": text.len(),
2659 "text_original_bytes": original_bytes,
2660 "text_truncated": truncated,
2661 });
2662 if let Some(max_bytes) = metadata_max_bytes {
2663 let object = payload
2664 .as_object_mut()
2665 .expect("message_submit payload is an object");
2666 object.insert(
2667 "session_id".to_string(),
2668 json!(bounded_message_submit_metadata(
2669 context.session_id.as_deref(),
2670 max_bytes
2671 )),
2672 );
2673 object.insert(
2674 "workspace".to_string(),
2675 json!(bounded_message_submit_metadata(
2676 context.workspace.as_ref().and_then(|path| path.to_str()),
2677 max_bytes
2678 )),
2679 );
2680 object.insert(
2681 "mode".to_string(),
2682 json!(bounded_message_submit_metadata(
2683 context.mode.as_deref(),
2684 max_bytes
2685 )),
2686 );
2687 object.insert(
2688 "model".to_string(),
2689 json!(bounded_message_submit_metadata(
2690 context.model.as_deref(),
2691 max_bytes
2692 )),
2693 );
2694 object.insert("total_tokens".to_string(), json!(context.total_tokens));
2695 }
2696 payload
2697 }
2698
2699 fn encoded_message_submit_payload_fits(payload: &serde_json::Value) -> bool {
2700 serde_json::to_vec(payload)
2701 .is_ok_and(|bytes| bytes.len() <= HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES)
2702 }
2703
2704 fn finalize_message_submit_payload(
2705 payload: serde_json::Value,
2706 original_bytes: usize,
2707 ) -> serde_json::Value {
2708 if encoded_message_submit_payload_fits(&payload) {
2709 return payload;
2710 }
2711
2712 // Serialization of a `Value` is infallible in practice, but the size
2713 // boundary is security-sensitive. If an invariant above ever regresses,
2714 // discard all user text and diagnostics rather than handing an oversized
2715 // document to a hook process.
2716 tracing::error!(target: "hooks", "message_submit payload fitter exceeded its hard byte cap");
2717 let fail_closed = build_message_submit_payload(
2718 &HookContext::new(),
2719 "",
2720 original_bytes,
2721 original_bytes != 0,
2722 None,
2723 );
2724 assert!(
2725 encoded_message_submit_payload_fits(&fail_closed),
2726 "minimal message_submit payload must fit the hard byte cap"
2727 );
2728 fail_closed
2729 }
2730
2731 /// Build the one canonical `message_submit` stdin document.
2732 ///
2733 /// Every producer — immediate input, restored queue entries, merged steers,
2734 /// and hook-to-hook replacements — crosses this serialization boundary. The
2735 /// largest UTF-8-safe text prefix that keeps the *serialized JSON* within the
2736 /// byte ceiling is retained, and explicit metadata tells the hook exactly
2737 /// what was clipped.
2738 pub(crate) fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Value {
2739 // Diagnostic metadata is useful but never allowed to crowd the actual
2740 // gate input out of the hard byte budget. Control-heavy strings can grow
2741 // sixfold when JSON-escaped, so try progressively smaller snapshots and
2742 // finally omit diagnostics altogether.
2743 let metadata_max_bytes = [
2744 Some(HOOK_MESSAGE_SUBMIT_METADATA_MAX_BYTES),
2745 Some(1_024),
2746 Some(256),
2747 None,
2748 ]
2749 .into_iter()
2750 .find(|metadata_max_bytes| {
2751 encoded_message_submit_payload_fits(&build_message_submit_payload(
2752 context,
2753 "",
2754 text.len(),
2755 !text.is_empty(),
2756 *metadata_max_bytes,
2757 ))
2758 })
2759 .unwrap_or(None);
2760
2761 if text.len() <= HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES {
2762 let complete =
2763 build_message_submit_payload(context, text, text.len(), false, metadata_max_bytes);
2764 if encoded_message_submit_payload_fits(&complete) {
2765 return finalize_message_submit_payload(complete, text.len());
2766 }
2767 }
2768
2769 // No candidate can retain more raw bytes than the full JSON budget. Build
2770 // at most that many UTF-8 boundaries even if a restored queue entry is
2771 // unexpectedly enormous.
2772 let raw_prefix_cap = text.len().min(HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES);
2773 let mut utf8_ends = Vec::with_capacity(raw_prefix_cap.saturating_add(1));
2774 utf8_ends.push(0);
2775 utf8_ends.extend(
2776 text.char_indices()
2777 .map(|(index, ch)| index + ch.len_utf8())
2778 .take_while(|end| *end <= raw_prefix_cap),
2779 );
2780
2781 let mut lower = 0usize;
2782 let mut upper = utf8_ends.len();
2783 while lower < upper {
2784 let middle = lower + (upper - lower) / 2;
2785 let end = utf8_ends[middle];
2786 let candidate = build_message_submit_payload(
2787 context,
2788 &text[..end],
2789 text.len(),
2790 true,
2791 metadata_max_bytes,
2792 );
2793 let fits = encoded_message_submit_payload_fits(&candidate);
2794 if fits {
2795 lower = middle + 1;
2796 } else {
2797 upper = middle;
2798 }
2799 }
2800
2801 let retained_end = utf8_ends[lower.saturating_sub(1)];
2802 finalize_message_submit_payload(
2803 build_message_submit_payload(
2804 context,
2805 &text[..retained_end],
2806 text.len(),
2807 true,
2808 metadata_max_bytes,
2809 ),
2810 text.len(),
2811 )
2812 }
2813
2814 pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value {
2815 let bounded_error = input
2816 .error
2817 .map(|error| sanitize_hook_text(error, HOOK_TURN_ERROR_MAX_CHARS));
2818 json!({
2819 "event": HookEvent::TurnEnd.as_str(),
2820 "session_id": input.context.session_id.as_deref(),
2821 "workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()),
2822 "mode": input.context.mode.as_deref(),
2823 "created_at": input.created_at.to_rfc3339(),
2824 "model_backed": input.model_backed,
2825 "provider": input.provider,
2826 "billing_surface": input.billing_surface,
2827 "model": input.model.or(input.context.model.as_deref()),
2828 "turn_id": input.turn_id,
2829 "status": input.status,
2830 "error": bounded_error,
2831 "duration_ms": duration_ms_saturating(input.duration),
2832 "usage": {
2833 "input_tokens": input.usage.input_tokens,
2834 "output_tokens": input.usage.output_tokens,
2835 "prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens,
2836 "prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens,
2837 "prompt_cache_write_tokens": input.usage.prompt_cache_write_tokens,
2838 "reasoning_tokens": input.usage.reasoning_tokens,
2839 "reasoning_replay_tokens": input.usage.reasoning_replay_tokens,
2840 },
2841 "totals": {
2842 "session_tokens": input.totals.session_tokens,
2843 "conversation_tokens": input.totals.conversation_tokens,
2844 "input_tokens": input.totals.input_tokens,
2845 "output_tokens": input.totals.output_tokens,
2846 },
2847 "tool_count": input.tool_count,
2848 "queued_message_count": input.queued_message_count,
2849 "stop_hook_active": false,
2850 })
2851 }
2852
2853 fn duration_ms_saturating(duration: Duration) -> u64 {
2854 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
2855 }
2856
2857 fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout {
2858 let trimmed = stdout.trim();
2859 if trimmed.is_empty() {
2860 return MessageSubmitStdout::Unchanged;
2861 }
2862
2863 let value: serde_json::Value = match serde_json::from_str(trimmed) {
2864 Ok(value) => value,
2865 Err(e) => return MessageSubmitStdout::Invalid(format!("invalid JSON: {e}")),
2866 };
2867
2868 let Some(object) = value.as_object() else {
2869 return MessageSubmitStdout::Invalid("stdout JSON must be an object".to_string());
2870 };
2871
2872 match object.get("text") {
2873 Some(serde_json::Value::String(text)) if !text.is_empty() => {
2874 if text.chars().count() > HOOK_MESSAGE_REPLACEMENT_MAX_CHARS {
2875 MessageSubmitStdout::Invalid(format!(
2876 "stdout `text` field exceeds {HOOK_MESSAGE_REPLACEMENT_MAX_CHARS} characters"
2877 ))
2878 } else {
2879 MessageSubmitStdout::Replaced(text.clone())
2880 }
2881 }
2882 Some(serde_json::Value::String(_)) => {
2883 MessageSubmitStdout::Invalid("stdout `text` field must not be empty".to_string())
2884 }
2885 Some(_) => MessageSubmitStdout::Invalid("stdout `text` field must be a string".to_string()),
2886 None => MessageSubmitStdout::Unchanged,
2887 }
2888 }
2889
2890 fn message_submit_continue_warning(result: &HookResult) -> Option<String> {
2891 message_submit_stdout_reason(&result.stdout)
2892 .or_else(|| {
2893 Some(generic_unavailable_detail(result.error.as_deref()))
2894 .filter(|detail| detail != "hook returned no verdict")
2895 })
2896 .or_else(|| {
2897 result
2898 .observed_exit_code()
2899 .map(|code| format!("message_submit hook exited with code {code}"))
2900 })
2901 }
2902
2903 fn message_submit_block_reason(result: &HookResult, fallback: &str) -> String {
2904 if let Some(reason) = message_submit_stdout_reason(&result.stdout) {
2905 return reason;
2906 }
2907 let detail = generic_unavailable_detail(result.error.as_deref());
2908 if detail != "hook returned no verdict" {
2909 return detail;
2910 }
2911 fallback.to_string()
2912 }
2913
2914 fn message_submit_stdout_reason(stdout: &str) -> Option<String> {
2915 let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
2916 value
2917 .get("reason")
2918 .and_then(serde_json::Value::as_str)
2919 .map(sanitize_hook_denial_reason)
2920 }
2921
2922 /// Largest single `shell_env` value that is accepted, in bytes.
2923 const SHELL_ENV_VALUE_MAX_BYTES: usize = 32 * 1024;
2924 /// Largest total `shell_env` contribution from one hook, in bytes of
2925 /// `KEY` + `VALUE`. Past this, later entries from that hook are dropped.
2926 const SHELL_ENV_TOTAL_MAX_BYTES: usize = 256 * 1024;
2927
2928 /// Whether a parsed name is usable as an environment variable name.
2929 ///
2930 /// `Command::env` **panics** on a key containing a NUL byte or `=`, and an
2931 /// empty key is meaningless, so an entry that fails this check is dropped
2932 /// rather than carried into `exec_shell`'s environment. A `shell_env` hook is
2933 /// a normal process whose stdout can contain anything — including a NUL
2934 /// straight out of a binary — and "the hook printed something odd" must never
2935 /// become "Codewhale aborted the tool call".
2936 fn is_valid_env_key(key: &str) -> bool {
2937 !key.is_empty()
2938 && !key.contains('=')
2939 && !key.chars().any(|c| c == '\0' || c.is_control() || c == ' ')
2940 }
2941
2942 /// Parse `KEY=VALUE\n` lines from a `shell_env` hook's stdout into a map.
2943 ///
2944 /// Tolerated: blank lines, leading whitespace, `#` comment lines (ignored),
2945 /// `export KEY=VALUE` (the `export ` prefix is dropped), surrounding quotes
2946 /// on the value. Lines without `=` are silently dropped — easier than
2947 /// failing the whole hook for one stray line of human-friendly output.
2948 /// Values are otherwise taken verbatim; we don't run them through a shell
2949 /// for variable expansion to avoid surprises.
2950 ///
2951 /// Rejected: entries whose key is unusable ([`is_valid_env_key`]), values
2952 /// containing a NUL byte, values over [`SHELL_ENV_VALUE_MAX_BYTES`], and
2953 /// anything past [`SHELL_ENV_TOTAL_MAX_BYTES`] of accumulated output. Each
2954 /// drop is logged by key name only — never by value.
2955 fn parse_env_lines(stdout: &str) -> HashMap<String, String> {
2956 let mut out: HashMap<String, String> = HashMap::new();
2957 let mut total_bytes = 0usize;
2958 for raw in stdout.lines() {
2959 let line = raw.trim();
2960 if line.is_empty() || line.starts_with('#') {
2961 continue;
2962 }
2963 let line = line.strip_prefix("export ").unwrap_or(line);
2964 let Some((key, value)) = line.split_once('=') else {
2965 continue;
2966 };
2967 let key = key.trim();
2968 if !is_valid_env_key(key) {
2969 tracing::warn!(
2970 target: "hooks",
2971 "shell_env hook produced an unusable variable name; dropping the entry"
2972 );
2973 continue;
2974 }
2975 let value = value.trim();
2976 let stripped = value
2977 .strip_prefix('"')
2978 .and_then(|v| v.strip_suffix('"'))
2979 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
2980 .unwrap_or(value);
2981 if stripped.contains('\0') {
2982 tracing::warn!(
2983 target: "hooks",
2984 key,
2985 "shell_env value contains a NUL byte; dropping the entry"
2986 );
2987 continue;
2988 }
2989 if stripped.len() > SHELL_ENV_VALUE_MAX_BYTES {
2990 tracing::warn!(
2991 target: "hooks",
2992 key,
2993 limit = SHELL_ENV_VALUE_MAX_BYTES,
2994 "shell_env value exceeds the per-value limit; dropping the entry"
2995 );
2996 continue;
2997 }
2998 let entry_bytes = key.len() + stripped.len();
2999 if total_bytes.saturating_add(entry_bytes) > SHELL_ENV_TOTAL_MAX_BYTES {
3000 tracing::warn!(
3001 target: "hooks",
3002 key,
3003 limit = SHELL_ENV_TOTAL_MAX_BYTES,
3004 "shell_env output exceeds the total limit; dropping the remaining entries"
3005 );
3006 break;
3007 }
3008 total_bytes += entry_bytes;
3009 out.insert(key.to_string(), stripped.to_string());
3010 }
3011 out
3012 }
3013
3014 // === Unit Tests ===
3015
3016 #[cfg(test)]
3017 mod tests {
3018 use super::*;
3019 use crate::test_support::{EnvVarGuard, lock_test_env};
3020 use std::collections::HashMap;
3021 use std::path::{Path, PathBuf};
3022
3023 fn trust_workspace_for_project_hooks(workspace: &Path, config_path: &Path) -> EnvVarGuard {
3024 let guard = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path);
3025 crate::config::save_workspace_trust(workspace).expect("save workspace trust");
3026 guard
3027 }
3028
3029 #[test]
3030 fn config_types_are_available_from_config_module() {
3031 let hook = crate::hooks::config::Hook::new(
3032 crate::hooks::config::HookEvent::SessionStart,
3033 "echo ready",
3034 );
3035 let config = crate::hooks::config::HooksConfig {
3036 enabled: true,
3037 hooks: vec![hook],
3038 ..Default::default()
3039 };
3040
3041 let hooks = config.hooks_for_event(crate::hooks::config::HookEvent::SessionStart);
3042
3043 assert_eq!(hooks.len(), 1);
3044 }
3045
3046 #[cfg(unix)]
3047 #[test]
3048 fn plugin_hook_runs_after_restart_and_process_spawn_rechecks_revocation() {
3049 let _lock = lock_test_env();
3050 let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
3051 let config = HooksConfig::load_with_project_and_plugins(
3052 HooksConfig {
3053 enabled: true,
3054 ..HooksConfig::default()
3055 },
3056 &fixture.workspace,
3057 Some(&fixture.registry),
3058 );
3059 assert!(config.problems.is_empty(), "{:?}", config.problems);
3060 assert_eq!(config.hooks.len(), 1);
3061 assert!(config.hooks[0].plugin_authority.is_some());
3062 let executor = HookExecutor::new(config, fixture.workspace.clone());
3063 let context = HookContext::new().with_workspace(fixture.workspace.clone());
3064
3065 let ran = executor.execute(HookEvent::SessionStart, &context);
3066 assert_eq!(ran.len(), 1);
3067 assert!(ran[0].success, "{:?}", ran[0].error);
3068 assert_eq!(
3069 std::fs::read_to_string(&fixture.marker).expect("plugin hook marker"),
3070 "plugin-hook-ran"
3071 );
3072 std::fs::remove_file(&fixture.marker).expect("clear marker");
3073
3074 let inactive = fixture.revoke_from_fresh_registry();
3075 let denied = executor.execute(HookEvent::SessionStart, &context);
3076 assert_eq!(denied.len(), 1);
3077 assert!(!denied[0].success);
3078 assert!(
3079 denied[0]
3080 .error
3081 .as_deref()
3082 .is_some_and(|error| error.contains("authority was denied")),
3083 "{:?}",
3084 denied[0].error
3085 );
3086 assert!(
3087 !fixture.marker.exists(),
3088 "revoked hook must be denied before process spawn"
3089 );
3090
3091 let reloaded = HooksConfig::load_with_project_and_plugins(
3092 HooksConfig {
3093 enabled: true,
3094 ..HooksConfig::default()
3095 },
3096 &fixture.workspace,
3097 Some(&inactive),
3098 );
3099 assert!(
3100 reloaded.hooks.is_empty(),
3101 "reload removes the revoked plugin Hook"
3102 );
3103 }
3104
3105 #[cfg(unix)]
3106 #[test]
3107 fn queued_plugin_hook_rechecks_revocation_at_dequeue() {
3108 let _lock = lock_test_env();
3109 let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
3110 let blocker_one = Hook::new(HookEvent::SessionStart, "sleep 1").background();
3111 let blocker_two = Hook::new(HookEvent::SessionStart, "sleep 1").background();
3112 let mut config = HooksConfig::load_with_project_and_plugins(
3113 HooksConfig {
3114 enabled: true,
3115 hooks: vec![blocker_one, blocker_two],
3116 ..HooksConfig::default()
3117 },
3118 &fixture.workspace,
3119 Some(&fixture.registry),
3120 );
3121 assert_eq!(config.hooks.len(), 3);
3122 config.hooks[2].background = true;
3123 let executor = HookExecutor::new(config, fixture.workspace.clone());
3124
3125 let submitted = executor.execute(
3126 HookEvent::SessionStart,
3127 &HookContext::new().with_workspace(fixture.workspace.clone()),
3128 );
3129 assert_eq!(submitted.len(), 3);
3130 assert!(submitted.iter().all(|result| result.background));
3131 fixture.revoke_from_fresh_registry();
3132
3133 std::thread::sleep(Duration::from_millis(1_500));
3134 assert!(
3135 !fixture.marker.exists(),
3136 "queued hook must recheck authority after the preceding job finishes"
3137 );
3138 }
3139
3140 #[test]
3141 fn executor_type_is_available_from_executor_module() {
3142 let executor = crate::hooks::executor::HookExecutor::disabled();
3143
3144 assert!(!executor.is_enabled());
3145 }
3146
3147 /// #456 — `parse_env_lines` covers the formats users actually emit from
3148 /// shell hooks: bare `KEY=VAL`, `export KEY=VAL`, quoted values, comments,
3149 /// blank lines. Lines without `=` are dropped; values are taken verbatim
3150 /// (no shell expansion).
3151 #[test]
3152 fn parse_env_lines_handles_realistic_hook_output() {
3153 let stdout = r#"
3154 # Aux comment line, ignored
3155 AWS_ACCESS_KEY_ID=AKIAEXAMPLE
3156 export GITHUB_TOKEN=ghp_examplevalue
3157 QUOTED="value with spaces"
3158 SINGLE='also valid'
3159
3160 = empty key dropped
3161 NOEQUAL line dropped
3162 "#;
3163 let parsed = super::parse_env_lines(stdout);
3164 assert_eq!(
3165 parsed.get("AWS_ACCESS_KEY_ID"),
3166 Some(&"AKIAEXAMPLE".to_string())
3167 );
3168 assert_eq!(
3169 parsed.get("GITHUB_TOKEN"),
3170 Some(&"ghp_examplevalue".to_string())
3171 );
3172 assert_eq!(parsed.get("QUOTED"), Some(&"value with spaces".to_string()));
3173 assert_eq!(parsed.get("SINGLE"), Some(&"also valid".to_string()));
3174 assert!(!parsed.contains_key(""));
3175 assert!(!parsed.contains_key("NOEQUAL line dropped"));
3176 // 4 valid entries above; nothing else.
3177 assert_eq!(parsed.len(), 4);
3178 }
3179
3180 /// #456 — empty stdout (or only blank/comments) yields an empty map.
3181 #[test]
3182 fn parse_env_lines_empty_when_no_assignments() {
3183 let parsed = super::parse_env_lines("# nothing\n\n \n");
3184 assert!(parsed.is_empty());
3185 }
3186
3187 #[test]
3188 fn parse_message_submit_stdout_replaces_text() {
3189 assert_eq!(
3190 super::parse_message_submit_stdout(r#"{"text":"changed"}"#),
3191 MessageSubmitStdout::Replaced("changed".to_string())
3192 );
3193 }
3194
3195 #[test]
3196 fn parse_message_submit_stdout_empty_is_unchanged() {
3197 assert_eq!(
3198 super::parse_message_submit_stdout(" \n\t "),
3199 MessageSubmitStdout::Unchanged
3200 );
3201 }
3202
3203 #[test]
3204 fn parse_message_submit_stdout_without_text_is_unchanged() {
3205 assert_eq!(
3206 super::parse_message_submit_stdout(r#"{"reason":"only used for blocks"}"#),
3207 MessageSubmitStdout::Unchanged
3208 );
3209 }
3210
3211 #[test]
3212 fn message_submit_payload_is_byte_bounded_after_json_escaping() {
3213 let original = "用户\"\\\n".repeat(20_000);
3214 let payload = super::message_submit_payload(
3215 &HookContext::new()
3216 .with_session_id("sess_test")
3217 .with_model("model"),
3218 &original,
3219 );
3220 let encoded = serde_json::to_vec(&payload).expect("serialize bounded payload");
3221
3222 assert!(
3223 encoded.len() <= super::HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES,
3224 "serialized payload was {} bytes",
3225 encoded.len()
3226 );
3227 assert_eq!(payload["text_truncated"], true);
3228 assert_eq!(payload["text_original_bytes"], original.len());
3229 let retained = payload["text"].as_str().expect("text string");
3230 assert_eq!(payload["text_bytes"], retained.len());
3231 assert!(original.starts_with(retained));
3232 assert!(std::str::from_utf8(retained.as_bytes()).is_ok());
3233 }
3234
3235 #[test]
3236 fn message_submit_payload_omits_hostile_diagnostics_before_exceeding_cap() {
3237 let hostile = "\u{0}\u{1}\u{1f}\"\\".repeat(8_000);
3238 let original = "\u{0}\"\\用户".repeat(20_000);
3239 let context = HookContext::new()
3240 .with_session_id(&hostile)
3241 .with_workspace(PathBuf::from(&hostile))
3242 .with_mode(&hostile)
3243 .with_model(&hostile);
3244 let payload = super::message_submit_payload(&context, &original);
3245 let encoded = serde_json::to_vec(&payload).expect("serialize hostile payload");
3246
3247 assert!(
3248 encoded.len() <= super::HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES,
3249 "serialized payload was {} bytes",
3250 encoded.len()
3251 );
3252 assert_eq!(payload["text_truncated"], true);
3253 assert_eq!(payload["text_original_bytes"], original.len());
3254 assert!(original.starts_with(payload["text"].as_str().expect("text")));
3255
3256 for key in ["session_id", "workspace", "mode", "model"] {
3257 if let Some(value) = payload.get(key).and_then(serde_json::Value::as_str) {
3258 assert!(
3259 value.len() <= super::HOOK_MESSAGE_SUBMIT_METADATA_MAX_BYTES + 16,
3260 "{key} was not bounded before serialization"
3261 );
3262 }
3263 }
3264 }
3265
3266 #[test]
3267 fn short_message_submit_payload_carries_explicit_untruncated_metadata() {
3268 let payload = super::message_submit_payload(&HookContext::new(), "hello 用户");
3269 assert_eq!(payload["text"], "hello 用户");
3270 assert_eq!(payload["text_bytes"], "hello 用户".len());
3271 assert_eq!(payload["text_original_bytes"], "hello 用户".len());
3272 assert_eq!(payload["text_truncated"], false);
3273 }
3274
3275 #[test]
3276 fn parse_message_submit_stdout_rejects_malformed_json() {
3277 assert!(matches!(
3278 super::parse_message_submit_stdout("not json"),
3279 MessageSubmitStdout::Invalid(_)
3280 ));
3281 }
3282
3283 #[test]
3284 fn parse_message_submit_stdout_rejects_non_string_text() {
3285 assert!(matches!(
3286 super::parse_message_submit_stdout(r#"{"text":123}"#),
3287 MessageSubmitStdout::Invalid(_)
3288 ));
3289 }
3290
3291 #[test]
3292 fn parse_message_submit_stdout_rejects_empty_text() {
3293 assert_eq!(
3294 super::parse_message_submit_stdout(r#"{"text":""}"#),
3295 MessageSubmitStdout::Invalid("stdout `text` field must not be empty".to_string())
3296 );
3297 }
3298
3299 #[test]
3300 fn parse_message_submit_stdout_rejects_non_object_json() {
3301 assert!(matches!(
3302 super::parse_message_submit_stdout(r#"["not", "an", "object"]"#),
3303 MessageSubmitStdout::Invalid(_)
3304 ));
3305 assert!(matches!(
3306 super::parse_message_submit_stdout(r#""not an object""#),
3307 MessageSubmitStdout::Invalid(_)
3308 ));
3309 }
3310
3311 #[test]
3312 fn test_hook_event_as_str() {
3313 assert_eq!(HookEvent::SessionStart.as_str(), "session_start");
3314 assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after");
3315 assert_eq!(HookEvent::ModeChange.as_str(), "mode_change");
3316 assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end");
3317 assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn");
3318 assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete");
3319 }
3320
3321 #[test]
3322 fn turn_end_payload_contains_post_turn_observer_fields() {
3323 let context = HookContext::new()
3324 .with_session_id("sess_test")
3325 .with_workspace(PathBuf::from("/tmp/codewhale"))
3326 .with_mode("agent")
3327 .with_model("deepseek-v4")
3328 .with_tokens(125);
3329 let usage = codewhale_models::Usage {
3330 input_tokens: 40,
3331 output_tokens: 9,
3332 prompt_cache_hit_tokens: Some(10),
3333 prompt_cache_miss_tokens: Some(30),
3334 prompt_cache_write_tokens: None,
3335 reasoning_tokens: Some(4),
3336 reasoning_replay_tokens: Some(2),
3337 server_tool_use: None,
3338 };
3339
3340 let payload = super::turn_end_payload(TurnEndPayloadInput {
3341 context: &context,
3342 created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"),
3343 model_backed: true,
3344 provider: Some("deepseek"),
3345 billing_surface: Some("test-payg"),
3346 model: Some("deepseek-v4-pro"),
3347 turn_id: "turn_123",
3348 status: "completed",
3349 error: None,
3350 duration: Duration::from_millis(321),
3351 usage: &usage,
3352 totals: TurnEndTotals {
3353 session_tokens: 125,
3354 conversation_tokens: 100,
3355 input_tokens: 100,
3356 output_tokens: 25,
3357 },
3358 tool_count: 2,
3359 queued_message_count: 1,
3360 });
3361
3362 assert_eq!(payload["event"], "turn_end");
3363 assert_eq!(payload["session_id"], "sess_test");
3364 assert_eq!(payload["workspace"], "/tmp/codewhale");
3365 assert_eq!(payload["mode"], "agent");
3366 assert_eq!(payload["created_at"], "2026-07-12T10:30:00+00:00");
3367 assert_eq!(payload["model_backed"], true);
3368 assert_eq!(payload["provider"], "deepseek");
3369 assert_eq!(payload["billing_surface"], "test-payg");
3370 assert!(payload.get("base_url").is_none());
3371 assert_eq!(payload["model"], "deepseek-v4-pro");
3372 assert_eq!(payload["turn_id"], "turn_123");
3373 assert_eq!(payload["status"], "completed");
3374 assert_eq!(payload["error"], serde_json::Value::Null);
3375 assert_eq!(payload["duration_ms"], 321);
3376 assert_eq!(payload["usage"]["input_tokens"], 40);
3377 assert_eq!(payload["usage"]["output_tokens"], 9);
3378 assert_eq!(payload["usage"]["prompt_cache_hit_tokens"], 10);
3379 assert_eq!(payload["usage"]["prompt_cache_miss_tokens"], 30);
3380 assert_eq!(payload["usage"]["reasoning_tokens"], 4);
3381 assert_eq!(payload["usage"]["reasoning_replay_tokens"], 2);
3382 assert_eq!(payload["totals"]["session_tokens"], 125);
3383 assert_eq!(payload["totals"]["conversation_tokens"], 100);
3384 assert_eq!(payload["totals"]["input_tokens"], 100);
3385 assert_eq!(payload["totals"]["output_tokens"], 25);
3386 assert_eq!(payload["tool_count"], 2);
3387 assert_eq!(payload["queued_message_count"], 1);
3388 assert_eq!(payload["stop_hook_active"], false);
3389 }
3390
3391 #[test]
3392 fn test_hook_context_to_env_vars() {
3393 let ctx = HookContext::new()
3394 .with_tool_name("exec_shell")
3395 .with_mode("agent")
3396 .with_workspace(PathBuf::from("/tmp"));
3397
3398 let env = ctx.to_env_vars();
3399
3400 assert_eq!(
3401 env.get("DEEPSEEK_TOOL_NAME"),
3402 Some(&"exec_shell".to_string())
3403 );
3404 assert_eq!(env.get("DEEPSEEK_MODE"), Some(&"agent".to_string()));
3405 assert_eq!(env.get("DEEPSEEK_WORKSPACE"), Some(&"/tmp".to_string()));
3406 }
3407
3408 #[test]
3409 fn test_hook_condition_always() {
3410 let hook = Hook::new(HookEvent::SessionStart, "echo test");
3411 let executor = HookExecutor::disabled();
3412 let context = HookContext::new();
3413
3414 assert!(executor.matches_condition(&hook, &context));
3415 }
3416
3417 #[test]
3418 fn test_hook_condition_tool_name() {
3419 let hook = Hook::new(HookEvent::ToolCallBefore, "echo test").with_condition(
3420 HookCondition::ToolName {
3421 name: "exec_shell".to_string(),
3422 },
3423 );
3424
3425 let executor = HookExecutor::disabled();
3426
3427 let context_match = HookContext::new().with_tool_name("exec_shell");
3428 let context_no_match = HookContext::new().with_tool_name("write_file");
3429
3430 assert!(executor.matches_condition(&hook, &context_match));
3431 assert!(!executor.matches_condition(&hook, &context_no_match));
3432 }
3433
3434 #[test]
3435 fn test_hook_condition_mode() {
3436 let hook =
3437 Hook::new(HookEvent::ModeChange, "echo test").with_condition(HookCondition::Mode {
3438 mode: "agent".to_string(),
3439 });
3440
3441 let executor = HookExecutor::disabled();
3442
3443 let context_match = HookContext::new().with_mode("AGENT"); // Case insensitive
3444 let context_no_match = HookContext::new().with_mode("normal");
3445
3446 assert!(executor.matches_condition(&hook, &context_match));
3447 assert!(!executor.matches_condition(&hook, &context_no_match));
3448 }
3449
3450 #[test]
3451 fn test_hooks_config_for_event() {
3452 let config = HooksConfig {
3453 enabled: true,
3454 hooks: vec![
3455 Hook::new(HookEvent::SessionStart, "echo start"),
3456 Hook::new(HookEvent::SessionEnd, "echo end"),
3457 Hook::new(HookEvent::SessionStart, "echo start2"),
3458 ],
3459 ..Default::default()
3460 };
3461
3462 let start_hooks = config.hooks_for_event(HookEvent::SessionStart);
3463 assert_eq!(start_hooks.len(), 2);
3464
3465 let end_hooks = config.hooks_for_event(HookEvent::SessionEnd);
3466 assert_eq!(end_hooks.len(), 1);
3467 }
3468
3469 #[test]
3470 fn test_hooks_config_disabled() {
3471 let config = HooksConfig {
3472 enabled: false,
3473 hooks: vec![Hook::new(HookEvent::SessionStart, "echo start")],
3474 ..Default::default()
3475 };
3476
3477 let hooks = config.hooks_for_event(HookEvent::SessionStart);
3478 assert!(hooks.is_empty());
3479 }
3480
3481 #[test]
3482 fn test_hook_builder() {
3483 let hook = Hook::new(HookEvent::ToolCallAfter, "notify.sh")
3484 .with_name("notify_tool")
3485 .with_timeout(60)
3486 .background()
3487 .with_condition(HookCondition::ToolCategory {
3488 category: "shell".to_string(),
3489 });
3490
3491 assert_eq!(hook.name, Some("notify_tool".to_string()));
3492 assert_eq!(hook.timeout_secs, 60);
3493 assert!(hook.background);
3494 assert!(matches!(
3495 hook.condition,
3496 Some(HookCondition::ToolCategory { .. })
3497 ));
3498 }
3499
3500 #[test]
3501 fn test_hook_timeout_enforced() {
3502 let command = if cfg!(windows) {
3503 "ping -n 3 127.0.0.1 > nul"
3504 } else {
3505 "sleep 2"
3506 };
3507 let hook = Hook::new(HookEvent::SessionStart, command).with_timeout(1);
3508 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
3509 let env_vars = HashMap::new();
3510
3511 let result = executor.execute_sync(&hook, &env_vars);
3512 assert!(!result.success);
3513 assert!(
3514 result
3515 .error
3516 .as_ref()
3517 .is_some_and(|e| e.contains("timed out"))
3518 );
3519 }
3520
3521 #[test]
3522 fn observer_hook_receives_eof_instead_of_inheriting_terminal_stdin() {
3523 const INNER_ENV: &str = "CODEWHALE_TEST_HOOK_EOF_INNER";
3524 const TEST_NAME: &str =
3525 "hooks::tests::observer_hook_receives_eof_instead_of_inheriting_terminal_stdin";
3526
3527 if std::env::var_os(INNER_ENV).is_some() {
3528 let dir = tempfile::tempdir().expect("tempdir");
3529 #[cfg(not(windows))]
3530 let command = write_hook_script(
3531 &dir,
3532 "read_to_eof.sh",
3533 r#"#!/bin/sh
3534 payload=$(cat)
3535 printf 'stdin-bytes=%s\n' "${#payload}"
3536 "#,
3537 );
3538 #[cfg(windows)]
3539 let command = "powershell -NoProfile -Command \"$value = [Console]::In.ReadToEnd(); [Console]::Out.WriteLine(('stdin-bytes=' + $value.Length))\"".to_string();
3540 // A cold PowerShell process can take several seconds to start on a
3541 // contended Windows CI runner. Keep the hook timeout finite so the
3542 // regression still detects an inherited live stdin pipe, while
3543 // allowing enough startup time for the EOF assertion itself.
3544 let hook_timeout_secs = if cfg!(windows) { 10 } else { 2 };
3545 let hook =
3546 Hook::new(HookEvent::ToolCallBefore, &command).with_timeout(hook_timeout_secs);
3547 let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
3548
3549 let result = executor.execute_sync(&hook, &HashMap::new());
3550 assert!(result.success, "stdin-less hook should finish: {result:?}");
3551 assert_eq!(result.stdout.trim(), "stdin-bytes=0");
3552 return;
3553 }
3554
3555 // Keep this subprocess's stdin pipe deliberately open. Before #4489,
3556 // the hook inherited that live pipe and blocked instead of receiving
3557 // EOF. The inner test can only finish when HookExecutor closes the
3558 // child's stdin write end.
3559 let mut child = Command::new(std::env::current_exe().expect("current test binary"))
3560 .args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"])
3561 .env(INNER_ENV, "1")
3562 .stdin(Stdio::piped())
3563 .spawn()
3564 .expect("spawn isolated hook EOF test");
3565 let held_open_stdin = child.stdin.take().expect("piped child stdin");
3566 // Leave headroom around the inner hook timeout so a cold Windows test
3567 // process can start, without weakening the held-open-pipe regression.
3568 let isolated_timeout_secs = if cfg!(windows) { 25 } else { 10 };
3569 let status = match child
3570 .wait_timeout(Duration::from_secs(isolated_timeout_secs))
3571 .expect("wait for isolated hook EOF test")
3572 {
3573 Some(status) => status,
3574 None => {
3575 let _ = child.kill();
3576 let _ = child.wait();
3577 panic!("isolated hook EOF test hung with parent stdin open");
3578 }
3579 };
3580 drop(held_open_stdin);
3581 assert!(status.success(), "isolated hook EOF test failed: {status}");
3582 }
3583
3584 #[cfg(not(windows))]
3585 #[test]
3586 fn timed_out_hook_kills_descendant_process_group() {
3587 let dir = tempfile::tempdir().expect("tempdir");
3588 let marker = dir.path().join("descendant-survived");
3589 let command = write_hook_script(
3590 &dir,
3591 "spawn_descendant.sh",
3592 &format!(
3593 "#!/bin/sh\n(sleep 2; printf leaked > '{}') &\nsleep 5\n",
3594 marker.display()
3595 ),
3596 );
3597 let hook = Hook::new(HookEvent::ToolCallBefore, &command).with_timeout(1);
3598 let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
3599
3600 let result = executor.execute_sync(&hook, &HashMap::new());
3601 assert!(
3602 result
3603 .error
3604 .as_ref()
3605 .is_some_and(|error| error.contains("timed out")),
3606 "hook should time out: {result:?}"
3607 );
3608 std::thread::sleep(Duration::from_millis(1_500));
3609 assert!(
3610 !marker.exists(),
3611 "the timed-out hook's descendant escaped its process group"
3612 );
3613 }
3614
3615 #[cfg(windows)]
3616 #[test]
3617 fn timed_out_hook_kills_windows_descendant_job() {
3618 let dir = tempfile::tempdir().expect("tempdir");
3619 let started = dir.path().join("descendant-started.txt");
3620 let survived = dir.path().join("descendant-survived.txt");
3621 let descendant = dir.path().join("descendant.cmd");
3622 std::fs::write(
3623 &descendant,
3624 "@echo off\r\necho started>descendant-started.txt\r\nping -n 5 127.0.0.1 > nul\r\necho survived>descendant-survived.txt\r\n",
3625 )
3626 .expect("write descendant script");
3627 let parent = dir.path().join("parent.cmd");
3628 std::fs::write(
3629 &parent,
3630 "@echo off\r\nstart \"\" /b cmd.exe /d /c descendant.cmd\r\n:wait_for_child\r\nif exist descendant-started.txt goto child_started\r\nping -n 2 127.0.0.1 > nul\r\ngoto wait_for_child\r\n:child_started\r\nping -n 10 127.0.0.1 > nul\r\n",
3631 )
3632 .expect("write parent script");
3633 let hook = Hook::new(HookEvent::ToolCallBefore, "call parent.cmd").with_timeout(3);
3634 let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
3635
3636 let result = executor.execute_sync(&hook, &HashMap::new());
3637 assert!(
3638 result
3639 .error
3640 .as_ref()
3641 .is_some_and(|error| error.contains("timed out")),
3642 "hook should time out: {result:?}"
3643 );
3644 assert!(
3645 started.exists(),
3646 "descendant never reached its start handshake"
3647 );
3648 std::thread::sleep(Duration::from_secs(5));
3649 assert!(
3650 !survived.exists(),
3651 "the timed-out hook's descendant escaped its Job Object"
3652 );
3653 }
3654
3655 #[cfg(not(windows))]
3656 #[test]
3657 fn message_submit_stdin_write_does_not_deadlock_when_hook_writes_first() {
3658 let dir = tempfile::tempdir().expect("tempdir");
3659 let command = write_hook_script(
3660 &dir,
3661 "write_before_read.sh",
3662 r#"#!/bin/sh
3663 dd if=/dev/zero bs=1024 count=256 2>/dev/null | tr '\000' x
3664 dd if=/dev/zero bs=1024 count=256 2>/dev/null | tr '\000' e >&2
3665 payload=$(cat)
3666 printf '\ndone:%s\n' "${#payload}"
3667 "#,
3668 );
3669 let hook = Hook::new(HookEvent::MessageSubmit, &command).with_timeout(5);
3670 let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
3671 let env_vars = HashMap::new();
3672 let payload = json!({
3673 "event": "message_submit",
3674 "text": "x".repeat(256 * 1024),
3675 });
3676
3677 let result = executor.execute_sync_with_stdin(&hook, &env_vars, &payload);
3678
3679 assert!(result.success, "hook should complete: {result:?}");
3680 assert!(result.stdout.ends_with("…[truncated]"));
3681 assert!(result.stderr.ends_with("…[truncated]"));
3682 assert!(result.stdout.len() <= HOOK_PIPE_CAPTURE_MAX_BYTES + 16);
3683 assert!(result.stderr.len() <= HOOK_PIPE_CAPTURE_MAX_BYTES + 16);
3684 }
3685
3686 #[test]
3687 fn test_executor_session_id() {
3688 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
3689
3690 assert!(executor.session_id().starts_with("sess_"));
3691 assert_eq!(executor.session_id().len(), 13); // "sess_" + 8 chars
3692 }
3693
3694 #[cfg(not(windows))]
3695 fn write_hook_script(dir: &tempfile::TempDir, name: &str, content: &str) -> String {
3696 let path = dir.path().join(name);
3697 std::fs::write(&path, content).expect("write hook script");
3698 format!("sh {}", path.display())
3699 }
3700
3701 #[cfg(not(windows))]
3702 fn submit_context(dir: &tempfile::TempDir) -> HookContext {
3703 HookContext::new()
3704 .with_session_id("sess_test")
3705 .with_workspace(dir.path().to_path_buf())
3706 .with_mode("agent")
3707 .with_model("deepseek-test")
3708 .with_tokens(42)
3709 }
3710
3711 #[cfg(not(windows))]
3712 #[test]
3713 fn json_observer_hook_receives_structured_stdin() {
3714 let dir = tempfile::tempdir().expect("tempdir");
3715 let out = dir.path().join("payload.json");
3716 let command = write_hook_script(
3717 &dir,
3718 "capture_observer.sh",
3719 &format!(
3720 r#"#!/bin/sh
3721 cat > "{}"
3722 "#,
3723 out.display()
3724 ),
3725 );
3726 let executor = HookExecutor::new(
3727 HooksConfig {
3728 enabled: true,
3729 hooks: vec![Hook::new(HookEvent::SubagentSpawn, &command)],
3730 ..Default::default()
3731 },
3732 dir.path().to_path_buf(),
3733 );
3734 let payload = json!({
3735 "event": "subagent_spawn",
3736 "agent_id": "agent_123",
3737 "prompt_preview": "inspect this",
3738 "prompt_truncated": false,
3739 });
3740
3741 let results = executor.execute_json_observer(
3742 HookEvent::SubagentSpawn,
3743 &submit_context(&dir),
3744 &payload,
3745 );
3746
3747 assert_eq!(results.len(), 1);
3748 assert!(results[0].success);
3749 let captured: serde_json::Value =
3750 serde_json::from_str(&std::fs::read_to_string(out).expect("payload written"))
3751 .expect("valid JSON payload");
3752 assert_eq!(captured["event"], "subagent_spawn");
3753 assert_eq!(captured["agent_id"], "agent_123");
3754 assert_eq!(captured["prompt_preview"], "inspect this");
3755 assert_eq!(captured["prompt_truncated"], false);
3756 }
3757
3758 #[cfg(not(windows))]
3759 #[test]
3760 fn turn_end_observer_hook_receives_stdin_json_and_ignores_stdout_contract() {
3761 let dir = tempfile::tempdir().expect("tempdir");
3762 let out = dir.path().join("turn_end.json");
3763 let command = write_hook_script(
3764 &dir,
3765 "capture_turn_end.sh",
3766 &format!(
3767 r#"#!/bin/sh
3768 cat > "{}"
3769 printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
3770 "#,
3771 out.display()
3772 ),
3773 );
3774 let executor = HookExecutor::new(
3775 HooksConfig {
3776 enabled: true,
3777 hooks: vec![Hook::new(HookEvent::TurnEnd, &command)],
3778 ..Default::default()
3779 },
3780 dir.path().to_path_buf(),
3781 );
3782 let usage = codewhale_models::Usage {
3783 input_tokens: 12,
3784 output_tokens: 3,
3785 prompt_cache_hit_tokens: None,
3786 prompt_cache_miss_tokens: None,
3787 prompt_cache_write_tokens: None,
3788 reasoning_tokens: None,
3789 reasoning_replay_tokens: None,
3790 server_tool_use: None,
3791 };
3792 let context = submit_context(&dir).with_tokens(15);
3793 let payload = super::turn_end_payload(TurnEndPayloadInput {
3794 context: &context,
3795 created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"),
3796 model_backed: true,
3797 provider: Some("openai"),
3798 billing_surface: None,
3799 model: Some("gpt-5.5"),
3800 turn_id: "turn_observed",
3801 status: "completed",
3802 error: None,
3803 duration: Duration::from_millis(7),
3804 usage: &usage,
3805 totals: TurnEndTotals {
3806 session_tokens: 15,
3807 conversation_tokens: 15,
3808 input_tokens: 12,
3809 output_tokens: 3,
3810 },
3811 tool_count: 0,
3812 queued_message_count: 0,
3813 });
3814
3815 let results = executor.execute_json_observer(HookEvent::TurnEnd, &context, &payload);
3816
3817 assert_eq!(results.len(), 1);
3818 assert!(results[0].success);
3819 assert!(
3820 results[0]
3821 .stdout
3822 .contains("stdout is not a mutation contract"),
3823 "stdout is still captured for diagnostics"
3824 );
3825 let captured: serde_json::Value =
3826 serde_json::from_str(&std::fs::read_to_string(out).expect("payload written"))
3827 .expect("valid JSON payload");
3828 assert_eq!(captured["event"], "turn_end");
3829 assert_eq!(captured["created_at"], "2026-07-12T10:30:00+00:00");
3830 assert_eq!(captured["provider"], "openai");
3831 assert_eq!(captured["model"], "gpt-5.5");
3832 assert_eq!(captured["turn_id"], "turn_observed");
3833 assert_eq!(captured["totals"]["input_tokens"], 12);
3834 assert_eq!(captured["totals"]["output_tokens"], 3);
3835 }
3836
3837 #[cfg(not(windows))]
3838 #[test]
3839 fn json_observer_hook_failure_does_not_stop_later_hooks() {
3840 let dir = tempfile::tempdir().expect("tempdir");
3841 let marker = dir.path().join("later-ran");
3842 let failing = write_hook_script(
3843 &dir,
3844 "failing_observer.sh",
3845 r#"#!/bin/sh
3846 echo boom >&2
3847 exit 1
3848 "#,
3849 );
3850 let later = write_hook_script(
3851 &dir,
3852 "later_observer.sh",
3853 &format!(
3854 r#"#!/bin/sh
3855 cat > "{}"
3856 "#,
3857 marker.display()
3858 ),
3859 );
3860 let mut first = Hook::new(HookEvent::SubagentComplete, &failing);
3861 first.continue_on_error = false;
3862 let executor = HookExecutor::new(
3863 HooksConfig {
3864 enabled: true,
3865 hooks: vec![first, Hook::new(HookEvent::SubagentComplete, &later)],
3866 ..Default::default()
3867 },
3868 dir.path().to_path_buf(),
3869 );
3870 let payload = json!({
3871 "event": "subagent_complete",
3872 "agent_id": "agent_456",
3873 "status": "completed",
3874 });
3875
3876 let results = executor.execute_json_observer(
3877 HookEvent::SubagentComplete,
3878 &submit_context(&dir),
3879 &payload,
3880 );
3881
3882 assert_eq!(results.len(), 2);
3883 assert!(!results[0].success);
3884 assert!(results[1].success);
3885 assert!(
3886 marker.exists(),
3887 "observer failures must be warn-only and non-blocking"
3888 );
3889 }
3890
3891 #[cfg(not(windows))]
3892 #[test]
3893 fn message_submit_transform_applies_hooks_in_order() {
3894 let dir = tempfile::tempdir().expect("tempdir");
3895 let first = write_hook_script(
3896 &dir,
3897 "first.sh",
3898 r#"#!/bin/sh
3899 printf '%s\n' '{"text":"first"}'
3900 "#,
3901 );
3902 let second = write_hook_script(
3903 &dir,
3904 "second.sh",
3905 r#"#!/bin/sh
3906 payload=$(cat)
3907 case "$payload" in
3908 *'"text":"first"'*) printf '%s\n' '{"text":"first second"}' ;;
3909 *) printf '%s\n' '{"text":"wrong"}' ;;
3910 esac
3911 "#,
3912 );
3913 let config = HooksConfig {
3914 enabled: true,
3915 hooks: vec![
3916 Hook::new(HookEvent::MessageSubmit, &first),
3917 Hook::new(HookEvent::MessageSubmit, &second),
3918 ],
3919 working_dir: Some(dir.path().to_path_buf()),
3920 ..HooksConfig::default()
3921 };
3922 let executor = HookExecutor::new(config, dir.path().to_path_buf());
3923
3924 assert_eq!(
3925 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
3926 MessageSubmitOutcome::replaced("first second".to_string())
3927 );
3928 }
3929
3930 #[cfg(not(windows))]
3931 #[test]
3932 fn message_submit_transform_exit_two_blocks_submission() {
3933 let dir = tempfile::tempdir().expect("tempdir");
3934 let command = write_hook_script(
3935 &dir,
3936 "block.sh",
3937 r#"#!/bin/sh
3938 printf '%s\n' '{"reason":"policy blocked this prompt"}'
3939 exit 2
3940 "#,
3941 );
3942 let config = HooksConfig {
3943 enabled: true,
3944 hooks: vec![Hook::new(HookEvent::MessageSubmit, &command)],
3945 working_dir: Some(dir.path().to_path_buf()),
3946 ..HooksConfig::default()
3947 };
3948 let executor = HookExecutor::new(config, dir.path().to_path_buf());
3949
3950 assert_eq!(
3951 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
3952 MessageSubmitOutcome::Blocked {
3953 reason: "policy blocked this prompt".to_string()
3954 }
3955 );
3956 }
3957
3958 #[cfg(not(windows))]
3959 #[test]
3960 fn background_message_submit_hook_is_observer_only() {
3961 let dir = tempfile::tempdir().expect("tempdir");
3962 let command = write_hook_script(
3963 &dir,
3964 "background.sh",
3965 r#"#!/bin/sh
3966 printf '%s\n' '{"text":"ignored"}'
3967 "#,
3968 );
3969 let config = HooksConfig {
3970 enabled: true,
3971 hooks: vec![Hook::new(HookEvent::MessageSubmit, &command).background()],
3972 working_dir: Some(dir.path().to_path_buf()),
3973 ..HooksConfig::default()
3974 };
3975 let executor = HookExecutor::new(config, dir.path().to_path_buf());
3976
3977 assert_eq!(
3978 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
3979 MessageSubmitOutcome::unchanged()
3980 );
3981 }
3982
3983 #[test]
3984 fn message_submit_transform_without_configured_hooks_is_unchanged() {
3985 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
3986
3987 assert_eq!(
3988 executor.execute_message_submit_transform(&HookContext::new(), "original"),
3989 MessageSubmitOutcome::unchanged()
3990 );
3991 }
3992
3993 #[cfg(not(windows))]
3994 #[test]
3995 fn message_submit_transform_skips_non_matching_condition() {
3996 let dir = tempfile::tempdir().expect("tempdir");
3997 let command = write_hook_script(
3998 &dir,
3999 "replace.sh",
4000 r#"#!/bin/sh
4001 printf '%s\n' '{"text":"should not apply"}'
4002 "#,
4003 );
4004 let hook =
4005 Hook::new(HookEvent::MessageSubmit, &command).with_condition(HookCondition::Mode {
4006 mode: "plan".into(),
4007 });
4008 let config = HooksConfig {
4009 enabled: true,
4010 hooks: vec![hook],
4011 working_dir: Some(dir.path().to_path_buf()),
4012 ..HooksConfig::default()
4013 };
4014 let executor = HookExecutor::new(config, dir.path().to_path_buf());
4015
4016 assert_eq!(
4017 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
4018 MessageSubmitOutcome::unchanged()
4019 );
4020 }
4021
4022 #[cfg(not(windows))]
4023 #[test]
4024 fn message_submit_continue_on_error_true_keeps_text_and_runs_later_hooks() {
4025 let dir = tempfile::tempdir().expect("tempdir");
4026 let failing = write_hook_script(
4027 &dir,
4028 "fail_continue.sh",
4029 r#"#!/bin/sh
4030 printf '%s\n' 'soft failure' >&2
4031 exit 9
4032 "#,
4033 );
4034 let replacing = write_hook_script(
4035 &dir,
4036 "replace_after_failure.sh",
4037 r#"#!/bin/sh
4038 printf '%s\n' '{"text":"recovered"}'
4039 "#,
4040 );
4041 let config = HooksConfig {
4042 enabled: true,
4043 hooks: vec![
4044 Hook::new(HookEvent::MessageSubmit, &failing),
4045 Hook::new(HookEvent::MessageSubmit, &replacing),
4046 ],
4047 working_dir: Some(dir.path().to_path_buf()),
4048 ..HooksConfig::default()
4049 };
4050 let executor = HookExecutor::new(config, dir.path().to_path_buf());
4051
4052 assert_eq!(
4053 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
4054 MessageSubmitOutcome::replaced("recovered".to_string())
4055 .with_warning(Some("message_submit hook exited with code 9".to_string()))
4056 );
4057 }
4058
4059 #[cfg(not(windows))]
4060 #[test]
4061 fn message_submit_timeout_continue_surfaces_warning_and_runs_later_hooks() {
4062 let dir = tempfile::tempdir().expect("tempdir");
4063 let slow = write_hook_script(
4064 &dir,
4065 "slow_continue.sh",
4066 r#"#!/bin/sh
4067 sleep 2
4068 "#,
4069 );
4070 let replacing = write_hook_script(
4071 &dir,
4072 "replace_after_timeout.sh",
4073 r#"#!/bin/sh
4074 printf '%s\n' '{"text":"after timeout"}'
4075 "#,
4076 );
4077 let mut slow_hook = Hook::new(HookEvent::MessageSubmit, &slow).with_timeout(1);
4078 slow_hook.continue_on_error = true;
4079 let config = HooksConfig {
4080 enabled: true,
4081 hooks: vec![slow_hook, Hook::new(HookEvent::MessageSubmit, &replacing)],
4082 working_dir: Some(dir.path().to_path_buf()),
4083 ..HooksConfig::default()
4084 };
4085 let executor = HookExecutor::new(config, dir.path().to_path_buf());
4086
4087 assert_eq!(
4088 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
4089 MessageSubmitOutcome::replaced("after timeout".to_string())
4090 .with_warning(Some("hook timed out after 1s".to_string()))
4091 );
4092 }
4093
4094 #[cfg(not(windows))]
4095 #[test]
4096 fn message_submit_invalid_stdout_keeps_text_and_runs_later_hooks() {
4097 let dir = tempfile::tempdir().expect("tempdir");
4098 let invalid = write_hook_script(
4099 &dir,
4100 "invalid_stdout.sh",
4101 r#"#!/bin/sh
4102 printf '%s\n' 'not json'
4103 "#,
4104 );
4105 let replacing = write_hook_script(
4106 &dir,
4107 "replace_after_invalid.sh",
4108 r#"#!/bin/sh
4109 printf '%s\n' '{"text":"valid later"}'
4110 "#,
4111 );
4112 let config = HooksConfig {
4113 enabled: true,
4114 hooks: vec![
4115 Hook::new(HookEvent::MessageSubmit, &invalid),
4116 Hook::new(HookEvent::MessageSubmit, &replacing),
4117 ],
4118 working_dir: Some(dir.path().to_path_buf()),
4119 ..HooksConfig::default()
4120 };
4121 let executor = HookExecutor::new(config, dir.path().to_path_buf());
4122
4123 assert_eq!(
4124 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
4125 MessageSubmitOutcome::replaced("valid later".to_string())
4126 );
4127 }
4128
4129 #[cfg(not(windows))]
4130 #[test]
4131 fn message_submit_continue_on_error_false_blocks_on_failure() {
4132 let dir = tempfile::tempdir().expect("tempdir");
4133 let command = write_hook_script(
4134 &dir,
4135 "fail.sh",
4136 r#"#!/bin/sh
4137 printf '%s\n' 'hard failure' >&2
4138 exit 7
4139 "#,
4140 );
4141 let mut hook = Hook::new(HookEvent::MessageSubmit, &command);
4142 hook.continue_on_error = false;
4143 let config = HooksConfig {
4144 enabled: true,
4145 hooks: vec![hook],
4146 working_dir: Some(dir.path().to_path_buf()),
4147 ..HooksConfig::default()
4148 };
4149 let executor = HookExecutor::new(config, dir.path().to_path_buf());
4150
4151 assert_eq!(
4152 executor.execute_message_submit_transform(&submit_context(&dir), "original"),
4153 MessageSubmitOutcome::Blocked {
4154 reason: "message_submit hook failed and blocked submission".to_string()
4155 }
4156 );
4157 }
4158
4159 #[test]
4160 fn has_hooks_for_event_fast_path_returns_false_for_empty_config() {
4161 let executor = HookExecutor::disabled();
4162 // No hooks configured AT ALL — every event is a fast skip.
4163 for event in [
4164 HookEvent::SessionStart,
4165 HookEvent::SessionEnd,
4166 HookEvent::MessageSubmit,
4167 HookEvent::ToolCallBefore,
4168 HookEvent::ToolCallAfter,
4169 HookEvent::ModeChange,
4170 HookEvent::OnError,
4171 HookEvent::TurnEnd,
4172 HookEvent::SubagentSpawn,
4173 HookEvent::SubagentComplete,
4174 ] {
4175 assert!(
4176 !executor.has_hooks_for_event(event),
4177 "empty config must short-circuit for {event:?}"
4178 );
4179 }
4180 }
4181
4182 #[test]
4183 fn has_hooks_for_event_returns_false_when_globally_disabled() {
4184 let config = HooksConfig {
4185 enabled: false,
4186 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo blocked")],
4187 ..HooksConfig::default()
4188 };
4189 let executor = HookExecutor::new(config, PathBuf::from("."));
4190 assert!(
4191 !executor.has_hooks_for_event(HookEvent::ToolCallBefore),
4192 "globally-disabled hooks must report no fires even when one is configured"
4193 );
4194 }
4195
4196 #[test]
4197 fn has_hooks_for_event_distinguishes_event_types() {
4198 let config = HooksConfig {
4199 enabled: true,
4200 hooks: vec![
4201 Hook::new(HookEvent::SessionStart, "echo start"),
4202 Hook::new(HookEvent::ToolCallBefore, "echo before"),
4203 ],
4204 ..HooksConfig::default()
4205 };
4206 let executor = HookExecutor::new(config, PathBuf::from("."));
4207 // Configured events return true.
4208 assert!(executor.has_hooks_for_event(HookEvent::SessionStart));
4209 assert!(executor.has_hooks_for_event(HookEvent::ToolCallBefore));
4210 // Unconfigured events return false even when other events are present.
4211 assert!(!executor.has_hooks_for_event(HookEvent::ToolCallAfter));
4212 assert!(!executor.has_hooks_for_event(HookEvent::OnError));
4213 assert!(!executor.has_hooks_for_event(HookEvent::ModeChange));
4214 }
4215
4216 // ── #3026: tool_call_before stdout decision contract ──────────────────
4217
4218 #[test]
4219 fn tool_call_before_stdout_parses_deny_with_reason() {
4220 let parsed =
4221 parse_tool_call_before_stdout(r#"{"decision":"deny","reason":"blocked by policy"}"#);
4222 assert_eq!(parsed.decision, Some(ToolCallDecision::Deny));
4223 assert_eq!(parsed.reason.as_deref(), Some("blocked by policy"));
4224 assert!(parsed.updated_input.is_none());
4225 assert!(parsed.additional_context.is_none());
4226 }
4227
4228 #[test]
4229 fn tool_call_before_stdout_parses_ask_and_allow() {
4230 let ask = parse_tool_call_before_stdout(r#"{"decision":"ask"}"#);
4231 assert_eq!(ask.decision, Some(ToolCallDecision::Ask));
4232
4233 let allow = parse_tool_call_before_stdout(r#"{"decision":"allow"}"#);
4234 assert_eq!(allow.decision, Some(ToolCallDecision::Allow));
4235 }
4236
4237 #[test]
4238 fn tool_call_before_stdout_parses_updated_input_object() {
4239 let parsed =
4240 parse_tool_call_before_stdout(r#"{"updatedInput":{"command":"ls -la","timeout":5}}"#);
4241 assert!(parsed.decision.is_none());
4242 assert_eq!(
4243 parsed.updated_input,
4244 Some(serde_json::json!({"command":"ls -la","timeout":5}))
4245 );
4246 }
4247
4248 #[test]
4249 fn tool_call_before_stdout_rejects_non_object_updated_input() {
4250 let parsed = parse_tool_call_before_stdout(r#"{"updatedInput":"rm -rf /"}"#);
4251 assert!(
4252 parsed.updated_input.is_none(),
4253 "updatedInput must be a JSON object"
4254 );
4255 let parsed = parse_tool_call_before_stdout(r#"{"updatedInput":[1,2]}"#);
4256 assert!(parsed.updated_input.is_none());
4257 }
4258
4259 #[test]
4260 fn tool_call_before_stdout_parses_additional_context() {
4261 let parsed =
4262 parse_tool_call_before_stdout(r#"{"additionalContext":"remember the style guide"}"#);
4263 assert_eq!(
4264 parsed.additional_context.as_deref(),
4265 Some("remember the style guide")
4266 );
4267 }
4268
4269 #[test]
4270 fn tool_call_before_stdout_empty_and_non_json_are_passthrough() {
4271 for stdout in ["", " \n ", "ok, proceeding", "exit code zero"] {
4272 let parsed = parse_tool_call_before_stdout(stdout);
4273 assert!(parsed.decision.is_none(), "stdout {stdout:?}");
4274 assert!(parsed.reason.is_none());
4275 assert!(parsed.updated_input.is_none());
4276 assert!(parsed.additional_context.is_none());
4277 }
4278 }
4279
4280 #[test]
4281 fn tool_call_before_stdout_json_without_decision_is_passthrough() {
4282 let parsed = parse_tool_call_before_stdout(r#"{"status":"fine"}"#);
4283 assert!(parsed.decision.is_none());
4284 }
4285
4286 #[test]
4287 fn tool_call_before_stdout_non_object_json_is_passthrough() {
4288 for stdout in [r#""deny""#, "[1,2,3]", "42", "true"] {
4289 let parsed = parse_tool_call_before_stdout(stdout);
4290 assert!(parsed.decision.is_none(), "stdout {stdout:?}");
4291 }
4292 }
4293
4294 #[test]
4295 fn tool_call_before_stdout_unknown_decision_treated_as_allow() {
4296 let parsed = parse_tool_call_before_stdout(r#"{"decision":"block"}"#);
4297 assert!(parsed.decision.is_none());
4298 }
4299
4300 // ── #3026: glob matchers for tool_name conditions ──────────────────────
4301
4302 #[test]
4303 fn tool_name_glob_matches_mcp_prefix() {
4304 assert!(HookExecutor::tool_name_matches_condition(
4305 "mcp__github__create_issue",
4306 "mcp__*"
4307 ));
4308 assert!(!HookExecutor::tool_name_matches_condition(
4309 "read_file",
4310 "mcp__*"
4311 ));
4312 }
4313
4314 #[test]
4315 fn tool_name_exact_match_still_works() {
4316 assert!(HookExecutor::tool_name_matches_condition(
4317 "read_file",
4318 "read_file"
4319 ));
4320 assert!(!HookExecutor::tool_name_matches_condition(
4321 "read_files",
4322 "read_file"
4323 ));
4324 }
4325
4326 #[test]
4327 fn tool_name_glob_escapes_regex_metacharacters() {
4328 // Without escaping, `.` would match any character.
4329 assert!(!HookExecutor::tool_name_matches_condition(
4330 "mcpXgithub",
4331 "mcp.git*"
4332 ));
4333 assert!(HookExecutor::tool_name_matches_condition(
4334 "mcp.github",
4335 "mcp.git*"
4336 ));
4337 // `+` and parens must be literal too.
4338 assert!(HookExecutor::tool_name_matches_condition(
4339 "weird+tool(name)",
4340 "weird+tool(*)"
4341 ));
4342 }
4343
4344 #[test]
4345 fn tool_name_glob_supports_infix_and_suffix_positions() {
4346 assert!(HookExecutor::tool_name_matches_condition(
4347 "mcp__github__create_issue",
4348 "mcp__*__create_issue"
4349 ));
4350 assert!(HookExecutor::tool_name_matches_condition(
4351 "task_shell_start",
4352 "*_shell_start"
4353 ));
4354 assert!(!HookExecutor::tool_name_matches_condition(
4355 "task_shell_wait",
4356 "*_shell_start"
4357 ));
4358 }
4359
4360 // ── #3026: project-local hooks ─────────────────────────────────────────
4361
4362 #[test]
4363 fn load_with_project_missing_file_keeps_global() {
4364 let dir = tempfile::tempdir().expect("tempdir");
4365 let global = HooksConfig {
4366 enabled: true,
4367 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
4368 ..HooksConfig::default()
4369 };
4370
4371 let merged = HooksConfig::load_with_project(global.clone(), dir.path());
4372 assert_eq!(merged.hooks.len(), 1);
4373 assert_eq!(merged.hooks[0].command, "echo global");
4374 }
4375
4376 #[test]
4377 fn load_with_project_appends_project_hooks_after_global() {
4378 let _lock = lock_test_env();
4379 let dir = tempfile::tempdir().expect("tempdir");
4380 let config_path = dir.path().join("user-config.toml");
4381 let _config = trust_workspace_for_project_hooks(dir.path(), &config_path);
4382 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
4383 let project_dir = dir.path().join(".codewhale");
4384 std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
4385 std::fs::write(
4386 project_dir.join("hooks.toml"),
4387 r#"
4388 [[hooks]]
4389 event = "tool_call_before"
4390 command = "echo project"
4391 "#,
4392 )
4393 .expect("write hooks.toml");
4394
4395 let global = HooksConfig {
4396 enabled: true,
4397 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
4398 ..HooksConfig::default()
4399 };
4400
4401 let (authority, _) = super::super::authority::review_project_hooks(dir.path()).unwrap();
4402 super::super::authority::approve_project_hooks(dir.path(), &authority.digest).unwrap();
4403 let merged = HooksConfig::load_with_project(global, dir.path());
4404 assert_eq!(merged.hooks.len(), 2);
4405 assert_eq!(
4406 merged.hooks[0].command, "echo global",
4407 "global hooks run first"
4408 );
4409 assert_eq!(
4410 merged.hooks[1].command, "echo project",
4411 "project hooks are appended after global"
4412 );
4413 }
4414
4415 #[test]
4416 fn load_with_project_ignores_project_hooks_until_workspace_trusted() {
4417 let _lock = lock_test_env();
4418 let dir = tempfile::tempdir().expect("tempdir");
4419 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
4420 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
4421 let project_dir = dir.path().join(".codewhale");
4422 std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
4423 std::fs::write(
4424 project_dir.join("hooks.toml"),
4425 r#"
4426 [[hooks]]
4427 event = "tool_call_before"
4428 command = "echo project"
4429 "#,
4430 )
4431 .expect("write hooks.toml");
4432
4433 let global = HooksConfig {
4434 enabled: true,
4435 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
4436 ..HooksConfig::default()
4437 };
4438
4439 let merged = HooksConfig::load_with_project(global, dir.path());
4440 assert_eq!(merged.hooks.len(), 1);
4441 assert_eq!(merged.hooks[0].command, "echo global");
4442 }
4443
4444 #[test]
4445 fn load_with_project_ignores_project_local_legacy_trust_marker() {
4446 let _lock = lock_test_env();
4447 let dir = tempfile::tempdir().expect("tempdir");
4448 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
4449 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
4450 let project_dir = dir.path().join(".codewhale");
4451 let legacy_trust_dir = dir.path().join(".deepseek");
4452 std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
4453 std::fs::create_dir_all(&legacy_trust_dir).expect("mkdir .deepseek");
4454 std::fs::write(legacy_trust_dir.join("trusted"), "").expect("write legacy trust marker");
4455 std::fs::write(
4456 project_dir.join("hooks.toml"),
4457 r#"
4458 [[hooks]]
4459 event = "tool_call_before"
4460 command = "echo project"
4461 "#,
4462 )
4463 .expect("write hooks.toml");
4464
4465 let global = HooksConfig {
4466 enabled: true,
4467 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
4468 ..HooksConfig::default()
4469 };
4470
4471 let merged = HooksConfig::load_with_project(global, dir.path());
4472 assert_eq!(merged.hooks.len(), 1);
4473 assert_eq!(merged.hooks[0].command, "echo global");
4474 }
4475
4476 #[test]
4477 fn load_with_project_malformed_file_falls_back_to_global() {
4478 let _lock = lock_test_env();
4479 let dir = tempfile::tempdir().expect("tempdir");
4480 let config_path = dir.path().join("user-config.toml");
4481 let _config = trust_workspace_for_project_hooks(dir.path(), &config_path);
4482 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
4483 let project_dir = dir.path().join(".codewhale");
4484 std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
4485 std::fs::write(project_dir.join("hooks.toml"), "this is [ not toml")
4486 .expect("write hooks.toml");
4487
4488 let global = HooksConfig {
4489 enabled: true,
4490 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
4491 ..HooksConfig::default()
4492 };
4493
4494 let merged = HooksConfig::load_with_project(global, dir.path());
4495 assert_eq!(merged.hooks.len(), 1, "malformed project file is ignored");
4496 assert_eq!(merged.hooks[0].command, "echo global");
4497 }
4498
4499 #[cfg(unix)]
4500 #[test]
4501 fn project_hooks_require_exact_review_at_load_and_every_spawn() {
4502 let _lock = lock_test_env();
4503 let dir = tempfile::tempdir().unwrap();
4504 let _config = trust_workspace_for_project_hooks(dir.path(), &dir.path().join("user.toml"));
4505 let _legacy = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
4506 std::fs::create_dir(dir.path().join(".codewhale")).unwrap();
4507 let hook_path = dir.path().join(".codewhale/hooks.toml");
4508 let contents = "[[hooks]]\nevent = \"session_start\"\ncommand = \"touch hook-ran\"\n";
4509 std::fs::write(&hook_path, contents).unwrap();
4510 let load = || {
4511 HooksConfig::load_with_project(
4512 HooksConfig {
4513 enabled: true,
4514 ..Default::default()
4515 },
4516 dir.path(),
4517 )
4518 };
4519 assert!(load().hooks.is_empty(), "folder trust is not hook approval");
4520 let (authority, _) = super::super::authority::review_project_hooks(dir.path()).unwrap();
4521 assert!(super::super::authority::approve_project_hooks(dir.path(), "bad-digest").is_err());
4522 super::super::authority::approve_project_hooks(dir.path(), &authority.digest).unwrap();
4523 let config = load();
4524 let hook = config.hooks[0].clone();
4525 let executor = HookExecutor::new(config, dir.path().to_path_buf());
4526 let good = executor.execute_sync(&hook, &HashMap::new());
4527 assert!(good.success, "{good:?}");
4528 std::fs::remove_file(dir.path().join("hook-ran")).unwrap();
4529 std::fs::write(&hook_path, format!("{contents}# changed\n")).unwrap();
4530 assert!(load().hooks.is_empty());
4531 assert!(!executor.execute_sync(&hook, &HashMap::new()).success);
4532 assert!(
4533 !executor
4534 .execute_background_inner(&hook, &HashMap::new(), None)
4535 .success
4536 );
4537 let queued = BackgroundHookJob {
4538 command: hook.command.clone(),
4539 env: HashMap::new(),
4540 working_dir: dir.path().to_path_buf(),
4541 stdin_bytes: None,
4542 label: "project".into(),
4543 timeout: Duration::from_secs(2),
4544 plugin_authority: None,
4545 project_authority: hook.project_authority.clone(),
4546 };
4547 queued.run();
4548 assert!(
4549 !dir.path().join("hook-ran").exists(),
4550 "queued work must revalidate"
4551 );
4552 std::fs::write(&hook_path, contents).unwrap();
4553 crate::config::save_workspace_hook_receipt(dir.path(), "").unwrap();
4554 assert!(!executor.execute_sync(&hook, &HashMap::new()).success);
4555 assert!(!dir.path().join("hook-ran").exists());
4556 }
4557
4558 #[cfg(unix)]
4559 #[test]
4560 fn project_hook_approval_rejects_symlinks_and_repository_receipts() {
4561 let _lock = lock_test_env();
4562 let dir = tempfile::tempdir().unwrap();
4563 let _config = trust_workspace_for_project_hooks(dir.path(), &dir.path().join("user.toml"));
4564 let _legacy = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
4565 std::fs::create_dir(dir.path().join(".codewhale")).unwrap();
4566 let target = dir.path().join("hook-source.toml");
4567 std::fs::write(
4568 &target,
4569 "[[hooks]]\nevent = \"session_start\"\ncommand = \"true\"\n",
4570 )
4571 .unwrap();
4572 let hook_path = dir.path().join(".codewhale/hooks.toml");
4573 std::os::unix::fs::symlink(&target, &hook_path).unwrap();
4574 assert!(super::super::authority::review_project_hooks(dir.path()).is_err());
4575 std::fs::remove_file(&hook_path).unwrap();
4576 std::fs::copy(&target, &hook_path).unwrap();
4577 let (authority, _) = super::super::authority::review_project_hooks(dir.path()).unwrap();
4578 std::fs::write(
4579 dir.path().join(".codewhale/config.toml"),
4580 format!("hooks_sha256 = \"{}\"", authority.digest),
4581 )
4582 .unwrap();
4583 assert!(
4584 HooksConfig::load_with_project(HooksConfig::default(), dir.path())
4585 .hooks
4586 .is_empty()
4587 );
4588 }
4589
4590 // === v0.9.2 hooks contract regression tests ===============================
4591 //
4592 // Each of these pins a claim that `docs/HOOKS.md` makes, so the docs cannot
4593 // drift ahead of the runtime again. All of them are provider-free: they
4594 // spawn `sh`, never a model.
4595
4596 #[test]
4597 fn background_result_is_a_submission_not_an_observed_exit_code() {
4598 // `background` is the flag that keeps "queued" from reading as
4599 // "exited 0". Steering paths gate on `observed_exit_code`.
4600 let submitted = HookResult {
4601 background: true,
4602 success: true,
4603 exit_code: None,
4604 ..HookResult::default()
4605 };
4606 assert!(submitted.background);
4607 assert_eq!(submitted.observed_exit_code(), None);
4608
4609 // A foreground deny still reads through.
4610 let denied = HookResult {
4611 background: false,
4612 success: false,
4613 exit_code: Some(2),
4614 ..HookResult::default()
4615 };
4616 assert_eq!(denied.observed_exit_code(), Some(2));
4617
4618 // A foreground timeout has no exit code either, but it is *not* a
4619 // background submission — callers must be able to tell them apart.
4620 let timed_out = HookResult {
4621 background: false,
4622 success: false,
4623 exit_code: None,
4624 error: Some("Hook timed out after 1s".to_string()),
4625 ..HookResult::default()
4626 };
4627 assert!(!timed_out.background);
4628 assert_eq!(timed_out.observed_exit_code(), None);
4629 }
4630
4631 #[test]
4632 fn default_timeout_secs_replaces_per_hook_timeout() {
4633 // Documented as-implemented: the global value overrides, it does not
4634 // merely fill in for hooks that omit one.
4635 let hook = Hook::new(HookEvent::SessionStart, "true").with_timeout(90);
4636 let overridden = HookExecutor::new(
4637 HooksConfig {
4638 default_timeout_secs: Some(5),
4639 ..HooksConfig::default()
4640 },
4641 PathBuf::from("."),
4642 );
4643 assert_eq!(overridden.effective_timeout_secs(&hook), 5);
4644
4645 let per_hook = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
4646 assert_eq!(per_hook.effective_timeout_secs(&hook), 90);
4647 }
4648
4649 #[test]
4650 fn foreground_timeout_result_is_bounded_and_carries_no_payload() {
4651 // The timeout result must not leak the stdin payload, the environment,
4652 // or partial output back to the caller.
4653 let command = if cfg!(windows) {
4654 "ping -n 4 127.0.0.1 > nul"
4655 } else {
4656 "echo secret-stdout; sleep 5"
4657 };
4658 let hook = Hook::new(HookEvent::MessageSubmit, command).with_timeout(1);
4659 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
4660 let payload = serde_json::json!({ "text": "super secret user text" });
4661
4662 let result = executor.execute_sync_with_stdin(&hook, &HashMap::new(), &payload);
4663
4664 assert!(!result.success);
4665 assert!(!result.background);
4666 assert_eq!(result.exit_code, None);
4667 assert!(result.stdout.is_empty(), "stdout leaked: {}", result.stdout);
4668 assert!(result.stderr.is_empty(), "stderr leaked: {}", result.stderr);
4669 let error = result.error.unwrap_or_default();
4670 assert!(error.contains("timed out"), "{error}");
4671 assert!(!error.contains("super secret"), "{error}");
4672 }
4673
4674 #[cfg(unix)]
4675 #[test]
4676 fn background_hook_timeout_kills_and_reaps_its_process_tree() {
4677 // The claim under test: "There is no path on which a timed-out hook
4678 // keeps running." A background hook used to be waited on with an
4679 // unbounded `child.wait()`, so a runaway command outlived the session.
4680 let dir = tempfile::tempdir().expect("tempdir");
4681 let marker = dir.path().join("survived.txt");
4682 // The inner `sh -c ... &` is a grandchild: killing only the immediate
4683 // shell would leave it alive, so this also covers process-group kill.
4684 let command = format!(
4685 "sh -c 'sleep 4; echo survived > {}' & wait",
4686 marker.display()
4687 );
4688 let hook = Hook::new(HookEvent::SessionStart, &command).with_timeout(1);
4689 let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
4690
4691 let result = executor.execute_background(&hook, &HashMap::new());
4692 assert!(result.background, "background submission must be flagged");
4693 assert_eq!(result.observed_exit_code(), None);
4694
4695 // Well past the hook's 1s budget, and past the 4s the command wanted.
4696 std::thread::sleep(Duration::from_secs(6));
4697 assert!(
4698 !marker.exists(),
4699 "background hook outlived its timeout and kept running"
4700 );
4701 }
4702
4703 #[cfg(unix)]
4704 #[test]
4705 fn background_hook_receives_the_same_stdin_payload_as_foreground() {
4706 // Background changes scheduling, not the payload contract.
4707 let dir = tempfile::tempdir().expect("tempdir");
4708 let out = dir.path().join("bg-stdin.json");
4709 let command = write_hook_script(
4710 &dir,
4711 "capture_bg_stdin.sh",
4712 &format!("#!/bin/sh\ncat > {}\n", out.display()),
4713 );
4714 let hook = Hook::new(HookEvent::MessageSubmit, &command)
4715 .with_name("bg")
4716 .background();
4717 let executor = HookExecutor::new(
4718 HooksConfig {
4719 enabled: true,
4720 hooks: vec![hook],
4721 ..HooksConfig::default()
4722 },
4723 dir.path().to_path_buf(),
4724 );
4725
4726 let context = submit_context(&dir);
4727 let outcome = executor.execute_message_submit_transform(&context, "hello world");
4728 // Background hooks cannot steer.
4729 assert_eq!(outcome, MessageSubmitOutcome::unchanged());
4730
4731 let raw = wait_for_captured_output(&out);
4732 let payload: serde_json::Value = serde_json::from_str(raw.trim()).expect("valid JSON");
4733 assert_eq!(payload["event"], "message_submit");
4734 assert_eq!(payload["text"], "hello world");
4735 assert_eq!(payload["session_id"], "sess_test");
4736 assert_eq!(payload["mode"], "agent");
4737 assert_eq!(payload["model"], "deepseek-test");
4738 assert_eq!(payload["total_tokens"], 42);
4739 }
4740
4741 #[cfg(unix)]
4742 #[test]
4743 fn background_hook_receives_the_documented_environment() {
4744 let dir = tempfile::tempdir().expect("tempdir");
4745 let out = dir.path().join("bg-env.txt");
4746 let command = write_hook_script(
4747 &dir,
4748 "capture_bg_env.sh",
4749 &format!(
4750 "#!/bin/sh\nprintf '%s|%s|%s\\n' \"$DEEPSEEK_SESSION_ID\" \"$DEEPSEEK_MODE\" \
4751 \"$DEEPSEEK_TOOL_NAME\" > {}\n",
4752 out.display()
4753 ),
4754 );
4755 let hook = Hook::new(HookEvent::ToolCallAfter, &command)
4756 .with_name("bg-env")
4757 .background();
4758 let executor = HookExecutor::new(
4759 HooksConfig {
4760 enabled: true,
4761 hooks: vec![hook],
4762 ..HooksConfig::default()
4763 },
4764 dir.path().to_path_buf(),
4765 );
4766
4767 let context = submit_context(&dir).with_tool_name("exec_shell");
4768 let results = executor.execute(HookEvent::ToolCallAfter, &context);
4769 assert_eq!(results.len(), 1);
4770 assert!(results[0].background);
4771
4772 let captured = wait_for_captured_output(&out);
4773 assert_eq!(captured.trim(), "sess_test|agent|exec_shell");
4774 }
4775
4776 /// Wait for a background hook's capture file to hold real bytes.
4777 ///
4778 /// The capture scripts redirect with `> out`, so the shell creates the
4779 /// file — empty — before `cat`/`printf` writes the payload. Polling for
4780 /// existence alone can win that race under load and read an empty capture,
4781 /// which surfaced in CI as `valid JSON: EOF while parsing a value` (#5929).
4782 /// Both captures are single small writes, so waiting for non-empty bytes
4783 /// means the write has landed without weakening what the tests assert.
4784 #[cfg(unix)]
4785 fn wait_for_captured_output(path: &std::path::Path) -> String {
4786 let deadline = std::time::Instant::now() + Duration::from_secs(10);
4787 loop {
4788 if let Ok(raw) = std::fs::read_to_string(path)
4789 && !raw.trim().is_empty()
4790 {
4791 return raw;
4792 }
4793 assert!(
4794 std::time::Instant::now() < deadline,
4795 "background hook wrote no output to {}",
4796 path.display()
4797 );
4798 std::thread::sleep(Duration::from_millis(50));
4799 }
4800 }
4801
4802 #[test]
4803 fn session_id_is_stable_across_every_event_and_survives_a_rebind() {
4804 // One TUI session, one `CODEWHALE_SESSION_ID`. The legacy
4805 // `DEEPSEEK_SESSION_ID` alias carries the same value so existing hook
4806 // records stay correlatable; assert both names over every event.
4807 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
4808 let session_id = executor.session_id().to_string();
4809 assert!(
4810 session_id.starts_with("sess_"),
4811 "unexpected session id shape: {session_id}"
4812 );
4813
4814 for event in crate::hooks::ALL_HOOK_EVENTS {
4815 let context = HookContext::new()
4816 .with_session_id(executor.session_id())
4817 .with_tool_name(event.as_str());
4818 let env = context.to_env_vars();
4819 assert_eq!(
4820 env.get("CODEWHALE_SESSION_ID"),
4821 Some(&session_id),
4822 "event `{}` reported a different Codewhale session id",
4823 event.as_str()
4824 );
4825 assert_eq!(
4826 env.get("DEEPSEEK_SESSION_ID"),
4827 Some(&session_id),
4828 "event `{}` reported a different legacy session id",
4829 event.as_str()
4830 );
4831 }
4832
4833 // A workspace switch or trust decision reloads the hook set. It must
4834 // not mint a new identity.
4835 let rebound = executor.rebind(
4836 HooksConfig {
4837 enabled: true,
4838 hooks: vec![Hook::new(HookEvent::SessionStart, "true")],
4839 ..HooksConfig::default()
4840 },
4841 PathBuf::from("/tmp"),
4842 );
4843 assert_eq!(rebound.session_id(), session_id);
4844 assert_eq!(rebound.config().hooks.len(), 1);
4845
4846 // A genuinely new executor is a genuinely new session.
4847 let fresh = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
4848 assert_ne!(fresh.session_id(), session_id);
4849 }
4850
4851 #[test]
4852 fn exit_code_condition_matches_only_a_real_exit_code() {
4853 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
4854 let hook = Hook::new(HookEvent::ToolCallAfter, "true")
4855 .with_condition(HookCondition::ExitCode { code: 1 });
4856
4857 // No exit code reported at all: must not match. Notably it must not be
4858 // satisfied by the failure flag either.
4859 let no_code = HookContext::new()
4860 .with_tool_name("read_file")
4861 .with_tool_result("boom", false, None);
4862 assert!(!executor.matches_condition(&hook, &no_code));
4863
4864 // A different exit code: no match.
4865 let other_code = HookContext::new()
4866 .with_tool_name("exec_shell")
4867 .with_tool_result("boom", false, Some(127));
4868 assert!(!executor.matches_condition(&hook, &other_code));
4869
4870 // The real thing.
4871 let exact = HookContext::new()
4872 .with_tool_name("exec_shell")
4873 .with_tool_result("boom", false, Some(1));
4874 assert!(executor.matches_condition(&hook, &exact));
4875
4876 // Exit code 0 on a successful call is a real code and matches a
4877 // `code = 0` predicate.
4878 let zero_hook = Hook::new(HookEvent::ToolCallAfter, "true")
4879 .with_condition(HookCondition::ExitCode { code: 0 });
4880 let zero = HookContext::new()
4881 .with_tool_name("exec_shell")
4882 .with_tool_result("ok", true, Some(0));
4883 assert!(executor.matches_condition(&zero_hook, &zero));
4884 assert!(!executor.matches_condition(&zero_hook, &no_code));
4885 }
4886
4887 #[test]
4888 fn tool_call_id_is_exported_for_correlation() {
4889 let env = HookContext::new()
4890 .with_tool_name("exec_shell")
4891 .with_tool_call_id("call_abc123")
4892 .to_env_vars();
4893 assert_eq!(
4894 env.get("CODEWHALE_TOOL_CALL_ID"),
4895 Some(&"call_abc123".to_string())
4896 );
4897 assert_eq!(
4898 env.get("DEEPSEEK_TOOL_CALL_ID"),
4899 Some(&"call_abc123".to_string())
4900 );
4901
4902 // Absent when unknown — never synthesized.
4903 let without = HookContext::new()
4904 .with_tool_name("exec_shell")
4905 .to_env_vars();
4906 assert!(!without.contains_key("CODEWHALE_TOOL_CALL_ID"));
4907 assert!(!without.contains_key("DEEPSEEK_TOOL_CALL_ID"));
4908 }
4909
4910 #[test]
4911 fn tool_exit_code_env_var_is_absent_when_the_tool_reported_none() {
4912 let with_code = HookContext::new()
4913 .with_tool_result("out", false, Some(3))
4914 .to_env_vars();
4915 assert_eq!(
4916 with_code.get("DEEPSEEK_TOOL_EXIT_CODE"),
4917 Some(&"3".to_string())
4918 );
4919 assert_eq!(
4920 with_code.get("DEEPSEEK_TOOL_SUCCESS"),
4921 Some(&"false".to_string())
4922 );
4923
4924 let without_code = HookContext::new()
4925 .with_tool_result("out", false, None)
4926 .to_env_vars();
4927 assert!(!without_code.contains_key("DEEPSEEK_TOOL_EXIT_CODE"));
4928 assert_eq!(
4929 without_code.get("DEEPSEEK_TOOL_SUCCESS"),
4930 Some(&"false".to_string())
4931 );
4932 }
4933
4934 #[test]
4935 fn payload_env_vars_are_bounded() {
4936 // Errors used to be the one unbounded field; a failed `exec_shell`
4937 // could push its whole output into `DEEPSEEK_ERROR`.
4938 let long = "x".repeat(20_000);
4939 let env = HookContext::new()
4940 .with_error(&long)
4941 .with_message(&long)
4942 .with_tool_result(&long, false, None)
4943 .to_env_vars();
4944
4945 for key in ["DEEPSEEK_ERROR", "DEEPSEEK_MESSAGE", "DEEPSEEK_TOOL_RESULT"] {
4946 let value = env.get(key).unwrap_or_else(|| panic!("{key} missing"));
4947 assert!(value.len() < 20_000, "{key} was not truncated");
4948 assert!(value.ends_with("...[truncated]"), "{key} lost its marker");
4949 }
4950 }
4951
4952 #[test]
4953 fn truncate_env_value_respects_utf8_boundaries() {
4954 // 4-byte characters straddling the cap must not panic or split.
4955 let value = "🐋".repeat(100);
4956 let truncated = super::truncate_env_value(&value, 10);
4957 assert!(truncated.ends_with("...[truncated]"));
4958 let head = truncated.trim_end_matches("...[truncated]");
4959 assert!(head.chars().all(|c| c == '🐋'));
4960 assert!(head.len() <= 12);
4961 }
4962
4963 #[cfg(unix)]
4964 #[test]
4965 fn collect_shell_env_merges_later_hooks_over_earlier_ones() {
4966 // The documented merge: parsed verbatim, later hooks win, failures
4967 // contribute nothing and do not abort.
4968 let dir = tempfile::tempdir().expect("tempdir");
4969 let first = write_hook_script(
4970 &dir,
4971 "env_first.sh",
4972 "#!/bin/sh\necho SHARED=first\necho ONLY_FIRST=1\n",
4973 );
4974 let second = write_hook_script(
4975 &dir,
4976 "env_second.sh",
4977 "#!/bin/sh\necho SHARED=second\necho QUOTED=\"has spaces\"\n",
4978 );
4979 let failing = write_hook_script(&dir, "env_fail.sh", "#!/bin/sh\necho NEVER=1\nexit 1\n");
4980
4981 let executor = HookExecutor::new(
4982 HooksConfig {
4983 enabled: true,
4984 hooks: vec![
4985 Hook::new(HookEvent::ShellEnv, &first).with_name("first"),
4986 Hook::new(HookEvent::ShellEnv, &second).with_name("second"),
4987 Hook::new(HookEvent::ShellEnv, &failing).with_name("failing"),
4988 ],
4989 ..HooksConfig::default()
4990 },
4991 dir.path().to_path_buf(),
4992 );
4993
4994 let context = HookContext::new().with_tool_name("exec_shell");
4995 let merged = executor.collect_shell_env(&context);
4996
4997 assert_eq!(merged.get("SHARED"), Some(&"second".to_string()));
4998 assert_eq!(merged.get("ONLY_FIRST"), Some(&"1".to_string()));
4999 assert_eq!(merged.get("QUOTED"), Some(&"has spaces".to_string()));
5000 assert!(
5001 !merged.contains_key("NEVER"),
5002 "a failing shell_env hook must contribute nothing"
5003 );
5004 }
5005
5006 #[cfg(unix)]
5007 #[test]
5008 fn shell_env_ignores_the_background_flag_and_still_collects_stdout() {
5009 // `background` is not honored here: the stdout IS the contract, so the
5010 // hook runs in the foreground regardless of how it is configured.
5011 let dir = tempfile::tempdir().expect("tempdir");
5012 let script = write_hook_script(&dir, "env_bg.sh", "#!/bin/sh\necho FROM_BG=yes\n");
5013 let executor = HookExecutor::new(
5014 HooksConfig {
5015 enabled: true,
5016 hooks: vec![
5017 Hook::new(HookEvent::ShellEnv, &script)
5018 .with_name("bg-shell-env")
5019 .background(),
5020 ],
5021 ..HooksConfig::default()
5022 },
5023 dir.path().to_path_buf(),
5024 );
5025
5026 let merged = executor.collect_shell_env(&HookContext::new().with_tool_name("exec_shell"));
5027 assert_eq!(merged.get("FROM_BG"), Some(&"yes".to_string()));
5028 }
5029
5030 #[cfg(unix)]
5031 #[test]
5032 fn shell_env_hook_receives_only_the_narrow_documented_context() {
5033 let dir = tempfile::tempdir().expect("tempdir");
5034 let out = dir.path().join("shell-env-context.txt");
5035 let script = write_hook_script(
5036 &dir,
5037 "env_context.sh",
5038 &format!(
5039 "#!/bin/sh\nprintf 'name=%s args=%s session=%s mode=%s\\n' \
5040 \"$DEEPSEEK_TOOL_NAME\" \"$DEEPSEEK_TOOL_ARGS\" \"$DEEPSEEK_SESSION_ID\" \
5041 \"$DEEPSEEK_MODE\" > {}\n",
5042 out.display()
5043 ),
5044 );
5045 let executor = HookExecutor::new(
5046 HooksConfig {
5047 enabled: true,
5048 hooks: vec![Hook::new(HookEvent::ShellEnv, &script)],
5049 ..HooksConfig::default()
5050 },
5051 dir.path().to_path_buf(),
5052 );
5053
5054 let context = HookContext::new()
5055 .with_tool_name("exec_shell")
5056 .with_tool_args(&serde_json::json!({ "command": "ls" }));
5057 let _ = executor.collect_shell_env(&context);
5058
5059 let captured = std::fs::read_to_string(&out).expect("shell_env hook wrote nothing");
5060 assert!(captured.contains("name=exec_shell"), "{captured}");
5061 assert!(captured.contains(r#""command":"ls""#), "{captured}");
5062 // No session id or mode is supplied for this event, which is why a
5063 // `mode` condition on `shell_env` is rejected at load.
5064 assert!(captured.contains("session= "), "{captured}");
5065 assert!(captured.trim_end().ends_with("mode="), "{captured}");
5066 }
5067
5068 /// Strictness has to travel on the result, because only the results tell
5069 /// you which hooks actually *matched* this call.
5070 #[cfg(unix)]
5071 #[test]
5072 fn results_carry_the_strictness_of_the_hook_that_produced_them() {
5073 let dir = tempfile::tempdir().expect("tempdir");
5074 let mut strict = Hook::new(HookEvent::ToolCallBefore, "true")
5075 .with_name("strict")
5076 .with_condition(HookCondition::ToolName {
5077 name: "write_file".to_string(),
5078 });
5079 strict.continue_on_error = false;
5080 let lenient = Hook::new(HookEvent::ToolCallBefore, "true")
5081 .with_name("lenient")
5082 .with_condition(HookCondition::ToolName {
5083 name: "exec_shell".to_string(),
5084 });
5085
5086 let executor = HookExecutor::new(
5087 HooksConfig {
5088 enabled: true,
5089 hooks: vec![strict, lenient],
5090 ..HooksConfig::default()
5091 },
5092 dir.path().to_path_buf(),
5093 );
5094
5095 // Only the lenient hook matches an `exec_shell` call, so nothing about
5096 // this call is strict — even though a strict hook exists in config.
5097 let shell = executor.execute(
5098 HookEvent::ToolCallBefore,
5099 &HookContext::new().with_tool_name("exec_shell"),
5100 );
5101 assert_eq!(shell.len(), 1);
5102 assert_eq!(shell[0].name.as_deref(), Some("lenient"));
5103 assert!(!shell[0].strict);
5104
5105 // The `write_file` call is the one the strict gate guards.
5106 let write = executor.execute(
5107 HookEvent::ToolCallBefore,
5108 &HookContext::new().with_tool_name("write_file"),
5109 );
5110 assert_eq!(write.len(), 1);
5111 assert_eq!(write[0].name.as_deref(), Some("strict"));
5112 assert!(write[0].strict);
5113 }
5114
5115 #[cfg(unix)]
5116 #[test]
5117 fn background_results_are_never_strict() {
5118 let dir = tempfile::tempdir().expect("tempdir");
5119 let mut hook = Hook::new(HookEvent::ToolCallBefore, "true")
5120 .with_name("bg-strict")
5121 .background();
5122 hook.continue_on_error = false;
5123 let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
5124
5125 let result = executor.execute_background(&hook, &HashMap::new());
5126 assert!(result.background);
5127 assert!(
5128 !result.strict,
5129 "nothing is awaited, so there is no answer to withhold"
5130 );
5131 }
5132
5133 /// A background hook that never reads stdin used to hang the supervising
5134 /// thread forever: the payload was written synchronously *before*
5135 /// `wait_timeout`, so a payload larger than the pipe buffer blocked, and
5136 /// the timeout / kill / reap below it were never reached.
5137 #[cfg(unix)]
5138 #[test]
5139 fn oversized_background_stdin_still_times_out_and_kills_the_tree() {
5140 let dir = tempfile::tempdir().expect("tempdir");
5141 let marker = dir.path().join("survived.txt");
5142 // Never reads stdin, and spawns a grandchild so this also covers the
5143 // process-group kill that the blocked write used to prevent.
5144 let command = write_hook_script(
5145 &dir,
5146 "ignores_stdin.sh",
5147 &format!(
5148 "#!/bin/sh\nsh -c 'sleep 6; echo survived > {}' &\nsleep 6\n",
5149 marker.display()
5150 ),
5151 );
5152 let hook = Hook::new(HookEvent::TurnEnd, &command)
5153 .with_name("deaf")
5154 .background()
5155 .with_timeout(1);
5156 let executor = HookExecutor::new(
5157 HooksConfig {
5158 enabled: true,
5159 hooks: vec![hook.clone()],
5160 ..HooksConfig::default()
5161 },
5162 dir.path().to_path_buf(),
5163 );
5164
5165 // Far beyond any pipe buffer (64 KiB on Linux, 8–64 KiB on macOS).
5166 let payload = json!({ "event": "turn_end", "blob": "x".repeat(4 * 1024 * 1024) });
5167
5168 let submitted = Instant::now();
5169 let result = executor.execute_background_with_stdin(&hook, &HashMap::new(), &payload);
5170 assert!(
5171 submitted.elapsed() < Duration::from_secs(2),
5172 "submission blocked on the stdin write: {:?}",
5173 submitted.elapsed()
5174 );
5175 assert!(result.background);
5176 assert!(result.success, "submission failed: {result:?}");
5177
5178 // Past the hook's 1s budget and past the 6s the command wanted.
5179 std::thread::sleep(Duration::from_secs(8));
5180 assert!(
5181 !marker.exists(),
5182 "background hook with an unread oversized stdin outlived its timeout"
5183 );
5184 }
5185
5186 #[test]
5187 fn spawn_failure_messages_carry_no_command_or_path() {
5188 let error = std::io::Error::new(
5189 std::io::ErrorKind::NotFound,
5190 "'C:\\Users\\dev\\secret hooks\\gate.cmd' is not recognized",
5191 );
5192 let message = super::spawn_failure_message(&error);
5193 assert!(message.contains("NotFound"), "{message}");
5194 assert!(!message.contains("gate.cmd"), "{message}");
5195 assert!(!message.contains("C:\\"), "{message}");
5196 assert!(!message.contains("secret"), "{message}");
5197 }
5198
5199 #[test]
5200 fn parse_env_lines_drops_nul_bearing_entries() {
5201 // `Command::env` panics on a NUL in a key or value, so a hook that
5202 // prints binary garbage must contribute nothing rather than take the
5203 // tool call down with it.
5204 let parsed = super::parse_env_lines("GOOD=fine\nBAD=tok\0en\nBA\0D2=x\nALSO_GOOD=2\n");
5205 assert_eq!(parsed.get("GOOD"), Some(&"fine".to_string()));
5206 assert_eq!(parsed.get("ALSO_GOOD"), Some(&"2".to_string()));
5207 assert!(!parsed.contains_key("BAD"), "{parsed:?}");
5208 assert_eq!(parsed.len(), 2, "{parsed:?}");
5209 for (key, value) in &parsed {
5210 assert!(!key.contains('\0'));
5211 assert!(!value.contains('\0'));
5212 // The invariants `Command::env` asserts on.
5213 assert!(!key.is_empty() && !key.contains('='));
5214 }
5215 }
5216
5217 #[test]
5218 fn parse_env_lines_bounds_values_and_the_aggregate() {
5219 let huge = "x".repeat(super::SHELL_ENV_VALUE_MAX_BYTES + 1);
5220 let parsed = super::parse_env_lines(&format!("OK=1\nHUGE={huge}\n"));
5221 assert_eq!(parsed.get("OK"), Some(&"1".to_string()));
5222 assert!(!parsed.contains_key("HUGE"), "over-long value was kept");
5223
5224 // Many individually-legal values still cannot add up to an unbounded
5225 // environment.
5226 let chunk = "y".repeat(16 * 1024);
5227 let mut stdout = String::new();
5228 for i in 0..64 {
5229 stdout.push_str(&format!("K{i}={chunk}\n"));
5230 }
5231 let bulk = super::parse_env_lines(&stdout);
5232 let total: usize = bulk.iter().map(|(k, v)| k.len() + v.len()).sum();
5233 assert!(total <= super::SHELL_ENV_TOTAL_MAX_BYTES, "{total} bytes");
5234 assert!(!bulk.is_empty(), "the bound must not drop everything");
5235 }
5236
5237 #[cfg(unix)]
5238 #[test]
5239 fn shell_env_hook_printing_nul_contributes_nothing_and_does_not_panic() {
5240 let dir = tempfile::tempdir().expect("tempdir");
5241 let script = write_hook_script(
5242 &dir,
5243 "env_nul.sh",
5244 "#!/bin/sh\nprintf 'TOKEN=abc\\000def\\n'\nprintf 'SAFE=ok\\n'\n",
5245 );
5246 let executor = HookExecutor::new(
5247 HooksConfig {
5248 enabled: true,
5249 hooks: vec![Hook::new(HookEvent::ShellEnv, &script).with_name("nul")],
5250 ..HooksConfig::default()
5251 },
5252 dir.path().to_path_buf(),
5253 );
5254
5255 let merged = executor.collect_shell_env(&HookContext::new().with_tool_name("exec_shell"));
5256 assert!(!merged.contains_key("TOKEN"), "{merged:?}");
5257 assert_eq!(merged.get("SAFE"), Some(&"ok".to_string()));
5258 // What `Command::env` would be handed must be panic-free.
5259 for (key, value) in &merged {
5260 assert!(!key.is_empty());
5261 assert!(!key.contains('=') && !key.contains('\0'));
5262 assert!(!value.contains('\0'));
5263 }
5264 }
5265
5266 #[test]
5267 fn tool_call_before_text_fields_are_sanitized_and_bounded() {
5268 let long = "z".repeat(super::HOOK_TEXT_FIELD_MAX_CHARS * 3);
5269 let stdout = serde_json::json!({
5270 "decision": "deny",
5271 "reason": format!("blocked\u{1b}[31m {long}"),
5272 "additionalContext": format!("ctx\u{0}\r\nline {long}"),
5273 })
5274 .to_string();
5275
5276 let parsed = super::parse_tool_call_before_stdout(&stdout);
5277
5278 let reason = parsed.reason.expect("reason kept");
5279 assert!(reason.chars().count() <= super::HOOK_TEXT_FIELD_MAX_CHARS + 16);
5280 assert!(reason.ends_with("…[truncated]"), "{reason}");
5281 assert!(!reason.contains('\u{1b}'), "escape sequence survived");
5282
5283 let context = parsed.additional_context.expect("context kept");
5284 assert!(context.chars().count() <= super::HOOK_TEXT_FIELD_MAX_CHARS + 16);
5285 assert!(!context.contains('\u{0}'));
5286 assert!(!context.contains('\r'));
5287 // Legitimate multi-line context still survives.
5288 assert!(
5289 context.contains('\n'),
5290 "{}",
5291 &context[..40.min(context.len())]
5292 );
5293 }
5294
5295 #[test]
5296 fn hook_context_bounds_tool_args_environment_value() {
5297 let env = HookContext::new()
5298 .with_tool_args(&serde_json::json!({
5299 "command": "x".repeat(super::HOOK_TOOL_ARGS_ENV_MAX_BYTES * 3)
5300 }))
5301 .to_env_vars();
5302 let args = env.get("DEEPSEEK_TOOL_ARGS").expect("tool args env");
5303 assert!(
5304 args.len() <= super::HOOK_TOOL_ARGS_ENV_MAX_BYTES + "...[truncated]".len(),
5305 "{} bytes",
5306 args.len()
5307 );
5308 assert!(args.ends_with("...[truncated]"));
5309 }
5310
5311 #[test]
5312 fn steering_objects_and_replacement_messages_have_independent_caps() {
5313 let oversized_input = serde_json::json!({
5314 "updatedInput": { "command": "x".repeat(super::HOOK_UPDATED_INPUT_MAX_BYTES * 2) }
5315 })
5316 .to_string();
5317 assert!(
5318 parse_tool_call_before_stdout(&oversized_input)
5319 .updated_input
5320 .is_none()
5321 );
5322
5323 let oversized_message = serde_json::json!({
5324 "text": "x".repeat(super::HOOK_MESSAGE_REPLACEMENT_MAX_CHARS + 1)
5325 })
5326 .to_string();
5327 assert!(matches!(
5328 super::parse_message_submit_stdout(&oversized_message),
5329 super::MessageSubmitStdout::Invalid(reason)
5330 if reason.contains("exceeds")
5331 ));
5332 }
5333
5334 #[test]
5335 fn turn_end_error_is_sanitized_and_bounded() {
5336 let context = HookContext::new();
5337 let usage = codewhale_models::Usage::default();
5338 let error = format!(
5339 "boom\u{1b}[2J{}",
5340 "x".repeat(super::HOOK_TURN_ERROR_MAX_CHARS * 2)
5341 );
5342 let payload = super::turn_end_payload(TurnEndPayloadInput {
5343 context: &context,
5344 created_at: chrono::Utc::now(),
5345 model_backed: true,
5346 provider: Some("test"),
5347 billing_surface: None,
5348 model: Some("test-model"),
5349 turn_id: "turn_test",
5350 status: "failed",
5351 error: Some(&error),
5352 duration: Duration::from_millis(1),
5353 usage: &usage,
5354 totals: TurnEndTotals {
5355 session_tokens: 0,
5356 conversation_tokens: 0,
5357 input_tokens: 0,
5358 output_tokens: 0,
5359 },
5360 tool_count: 0,
5361 queued_message_count: 0,
5362 });
5363 let rendered = payload["error"].as_str().expect("bounded error");
5364 assert!(!rendered.contains('\u{1b}'));
5365 assert!(rendered.ends_with("…[truncated]"));
5366 assert!(
5367 rendered.chars().count() <= super::HOOK_TURN_ERROR_MAX_CHARS + 16,
5368 "{} chars",
5369 rendered.chars().count()
5370 );
5371 }
5372
5373 #[test]
5374 fn denial_reason_redacts_paths_arguments_and_secret_assignments() {
5375 let rendered = super::sanitize_hook_denial_reason(
5376 "denied /Users/alice/private --command token=SUPERSECRET safe",
5377 );
5378 assert_eq!(rendered, "denied [path] [argument] [secret] safe");
5379 assert!(!rendered.contains("alice"));
5380 assert!(!rendered.contains("SUPERSECRET"));
5381 assert!(!rendered.contains("--command"));
5382
5383 let command = super::sanitize_hook_denial_reason("blocked command rm bearer abc123");
5384 assert_eq!(command, "blocked [command] [command] [secret] [secret]");
5385 assert!(!command.contains("rm"));
5386 assert!(!command.contains("abc123"));
5387 }
5388
5389 #[test]
5390 fn denial_reason_redacts_adversarial_header_path_and_command_forms() {
5391 for reason in [
5392 r#"Denied Authorization: Bearer TOPSECRET path="/Users/alice/private key" command='rm -rf /tmp/private' safe"#,
5393 r#"Denied authorization:"Bearer TOPSECRET" path=../private command="curl --header secret" safe"#,
5394 r#"Denied (Authorization: Bearer TOPSECRET), path = C:\private command = "powershell -enc SECRET" safe"#,
5395 ] {
5396 let rendered = super::sanitize_hook_denial_reason(reason);
5397 for secret in [
5398 "TOPSECRET",
5399 "alice",
5400 "private key",
5401 "../private",
5402 "C:\\private",
5403 "curl",
5404 "powershell",
5405 "SECRET",
5406 ] {
5407 assert!(!rendered.contains(secret), "leaked {secret}: {rendered}");
5408 }
5409 assert!(rendered.contains("[secret]"), "{rendered}");
5410 assert!(rendered.contains("[path]"), "{rendered}");
5411 assert!(rendered.contains("[command]"), "{rendered}");
5412 }
5413 }
5414
5415 #[test]
5416 fn denial_reason_redacts_auth_schemes_normalized_secrets_and_relative_paths() {
5417 for reason in [
5418 "Denied Authorization: Basic dXNlcjpwYXNz src/private/config.toml",
5419 "Denied Authorization=Digest deadbeef service.API-KEY=topsecret",
5420 "Denied authorization Negotiate kerberos AWS_SESSION_TOKEN=abc123",
5421 "Denied authorization NTLM credential internal_secret=hunter2",
5422 "Denied authorization Proprietary-Scheme opaque-credential src/private/key.txt",
5423 ] {
5424 let rendered = super::sanitize_hook_denial_reason(reason);
5425 for sensitive in [
5426 "dXNlcjpwYXNz",
5427 "deadbeef",
5428 "kerberos",
5429 "credential",
5430 "topsecret",
5431 "abc123",
5432 "hunter2",
5433 "src/private/config.toml",
5434 "opaque-credential",
5435 "src/private/key.txt",
5436 ] {
5437 assert!(
5438 !rendered.contains(sensitive),
5439 "leaked {sensitive}: {rendered}"
5440 );
5441 }
5442 assert!(rendered.contains("[secret]"), "{rendered}");
5443 }
5444 }
5445
5446 #[test]
5447 fn observer_dispatch_failures_are_event_specific_and_fixed() {
5448 let config = HooksConfig {
5449 enabled: true,
5450 hooks: vec![Hook::new(HookEvent::TurnEnd, "true")],
5451 ..HooksConfig::default()
5452 };
5453 let mut full = HookExecutor::new(config.clone(), PathBuf::from("."));
5454 full.inject_observer_dispatch_full_for_test();
5455 let error = full
5456 .submit_observer(HookEvent::TurnEnd, HookContext::new())
5457 .expect_err("full queue must be visible");
5458 assert_eq!(
5459 error,
5460 "turn_end observer hook queue is full; event was not submitted"
5461 );
5462
5463 let mut disconnected = HookExecutor::new(config, PathBuf::from("."));
5464 disconnected.inject_observer_dispatch_disconnect_for_test();
5465 let error = disconnected
5466 .submit_observer(HookEvent::TurnEnd, HookContext::new())
5467 .expect_err("disconnected dispatcher must be visible");
5468 assert_eq!(
5469 error,
5470 "turn_end observer hook dispatcher is unavailable; event was not submitted"
5471 );
5472 }
5473
5474 #[test]
5475 fn observer_context_is_bounded_before_enqueue() {
5476 let huge = "用户".repeat(20_000);
5477 let bounded = HookContext {
5478 tool_args: Some(huge.clone()),
5479 tool_result: Some(huge.clone()),
5480 error_message: Some(huge.clone()),
5481 message: Some(huge.clone()),
5482 model: Some(huge),
5483 ..HookContext::new()
5484 }
5485 .bounded_for_observer();
5486
5487 assert!(bounded.tool_args.expect("args").len() <= super::HOOK_TOOL_ARGS_ENV_MAX_BYTES + 16);
5488 assert!(
5489 bounded.tool_result.expect("result").len()
5490 <= super::HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES + 16
5491 );
5492 assert!(
5493 bounded.error_message.expect("error").len() <= super::HOOK_ERROR_CONTEXT_MAX_BYTES + 16
5494 );
5495 assert!(
5496 bounded.message.expect("message").len() <= super::HOOK_MESSAGE_CONTEXT_MAX_BYTES + 16
5497 );
5498 assert!(
5499 bounded.model.expect("model").len() <= super::HOOK_OBSERVER_METADATA_MAX_BYTES + 16
5500 );
5501 }
5502
5503 #[test]
5504 fn background_supervisor_saturation_is_a_failed_submission() {
5505 let hook = Hook::new(HookEvent::TurnEnd, "true").background();
5506 let mut executor = HookExecutor::new(
5507 HooksConfig {
5508 enabled: true,
5509 hooks: vec![hook],
5510 ..HooksConfig::default()
5511 },
5512 PathBuf::from("."),
5513 );
5514 executor.inject_background_supervisor_full_for_test();
5515
5516 let results = executor.execute(HookEvent::TurnEnd, &HookContext::new());
5517 assert_eq!(results.len(), 1);
5518 assert!(results[0].background);
5519 assert!(!results[0].success);
5520 assert_eq!(
5521 results[0].error.as_deref(),
5522 Some("background hook supervisor queue is full")
5523 );
5524 }
5525
5526 #[cfg(not(windows))]
5527 #[test]
5528 fn bounded_observer_dispatcher_executes_a_submitted_event() {
5529 let dir = tempfile::tempdir().expect("tempdir");
5530 let receipt = dir.path().join("observer-receipt.json");
5531 let command = write_hook_script(
5532 &dir,
5533 "persistent_observer.sh",
5534 &format!("#!/bin/sh\ncat > '{}'\n", receipt.display()),
5535 );
5536 let executor = HookExecutor::new(
5537 HooksConfig {
5538 enabled: true,
5539 hooks: vec![Hook::new(HookEvent::TurnEnd, &command)],
5540 ..HooksConfig::default()
5541 },
5542 dir.path().to_path_buf(),
5543 );
5544 executor
5545 .submit_json_observer(
5546 HookEvent::TurnEnd,
5547 HookContext::new(),
5548 serde_json::json!({"event": "turn_end", "turn_id": "turn_test"}),
5549 )
5550 .expect("bounded submission");
5551
5552 let deadline = Instant::now() + Duration::from_secs(2);
5553 let payload = loop {
5554 if let Ok(raw) = std::fs::read_to_string(&receipt)
5555 && let Ok(payload) = serde_json::from_str::<serde_json::Value>(&raw)
5556 {
5557 break payload;
5558 }
5559 assert!(
5560 Instant::now() < deadline,
5561 "persistent worker did not finish a valid receipt"
5562 );
5563 std::thread::sleep(Duration::from_millis(10));
5564 };
5565 assert_eq!(payload["turn_id"], "turn_test");
5566 }
5567
5568 #[cfg(unix)]
5569 #[test]
5570 fn explicit_message_denial_never_copies_raw_process_diagnostics() {
5571 let dir = tempfile::tempdir().expect("tempdir");
5572 let command = r#"printf '%s\n' '{"reason":"blocked /Users/alice/private --run token=SUPERSECRET"}'; printf '%s\n' 'stderr-secret /tmp/private' >&2; exit 2"#;
5573 let executor = HookExecutor::new(
5574 HooksConfig {
5575 enabled: true,
5576 hooks: vec![Hook::new(HookEvent::MessageSubmit, command)],
5577 ..HooksConfig::default()
5578 },
5579 dir.path().to_path_buf(),
5580 );
5581 let outcome = executor.execute_message_submit_transform(&HookContext::new(), "hello");
5582 let MessageSubmitOutcome::Blocked { reason } = outcome else {
5583 panic!("expected explicit block");
5584 };
5585 assert_eq!(reason, "blocked [path] [argument] [secret]");
5586 for secret in ["alice", "SUPERSECRET", "stderr-secret", "/tmp/private"] {
5587 assert!(!reason.contains(secret), "leaked {secret}: {reason}");
5588 }
5589 }
5590
5591 #[cfg(unix)]
5592 #[test]
5593 fn foreground_pipe_capture_is_bounded_while_verbose_child_is_drained() {
5594 let hook = Hook::new(
5595 HookEvent::SessionStart,
5596 "head -c 200000 /dev/zero | tr '\\0' o; head -c 200000 /dev/zero | tr '\\0' e >&2",
5597 )
5598 .with_timeout(5);
5599 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
5600 let result = executor.execute_sync(&hook, &HashMap::new());
5601 assert!(result.success, "{:?}", result.error);
5602 for output in [&result.stdout, &result.stderr] {
5603 assert!(output.ends_with("…[truncated]"));
5604 assert!(
5605 output.len() <= super::HOOK_PIPE_CAPTURE_MAX_BYTES + "…[truncated]".len(),
5606 "{} bytes",
5607 output.len()
5608 );
5609 }
5610 }
5611
5612 #[cfg(unix)]
5613 #[test]
5614 fn helper_wait_and_uncontained_reap_paths_are_bounded() {
5615 // These helpers reap one immediate child. A shell can fork `sleep`
5616 // and leave that descendant holding the test's output pipes.
5617 let mut helper = Command::new("sleep")
5618 .arg("30")
5619 .spawn()
5620 .expect("spawn helper");
5621 let started = Instant::now();
5622 let error = super::wait_for_helper_status(&mut helper, Duration::from_millis(20))
5623 .expect_err("slow helper must time out");
5624 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
5625 assert!(started.elapsed() < super::HOOK_REAP_TIMEOUT + Duration::from_secs(1));
5626 assert!(matches!(helper.try_wait(), Ok(Some(_))));
5627
5628 let mut uncontained = Command::new("sleep")
5629 .arg("30")
5630 .spawn()
5631 .expect("spawn uncontained child");
5632 assert!(super::kill_and_reap_immediate_child(
5633 &mut uncontained,
5634 Duration::from_secs(1)
5635 ));
5636 assert!(matches!(uncontained.try_wait(), Ok(Some(_))));
5637 }
5638
5639 #[test]
5640 fn sanitize_hook_text_keeps_short_text_verbatim() {
5641 assert_eq!(
5642 super::sanitize_hook_text("plain reason", 100),
5643 "plain reason"
5644 );
5645 assert_eq!(super::sanitize_hook_text("a\tb\nc", 100), "a\tb\nc");
5646 assert_eq!(super::sanitize_hook_text("", 100), "");
5647 }
5648
5649 #[test]
5650 fn sanitize_hook_line_flattens_structure_characters() {
5651 assert_eq!(super::sanitize_hook_line("a\tb\nc", 100), "a b c");
5652 assert_eq!(super::sanitize_hook_line("a\u{1b}[2Jb\r", 100), "a [2Jb");
5653 }
5654
5655 #[test]
5656 fn sanitize_hook_label_bounds_and_defangs_operator_names() {
5657 let noisy = format!("\u{1b}[2Jgate\twith\nnoise{}", "x".repeat(1_000));
5658 let label = super::sanitize_hook_label(Some(&noisy));
5659 assert!(!label.contains('\u{1b}'), "{label}");
5660 assert!(!label.contains('\n') && !label.contains('\t'), "{label}");
5661 assert!(label.contains("gate"), "{label}");
5662 assert!(
5663 label.chars().count() <= super::HOOK_LABEL_MAX_CHARS + 16,
5664 "{} chars",
5665 label.chars().count()
5666 );
5667
5668 assert_eq!(super::sanitize_hook_label(None), "(unnamed)");
5669 assert_eq!(super::sanitize_hook_label(Some("")), "(unnamed)");
5670 assert_eq!(super::sanitize_hook_label(Some(" \t ")), "(unnamed)");
5671 assert_eq!(super::sanitize_hook_label(Some(" gate ")), "gate");
5672 }
5673
5674 /// The point of the boundary: recognized failures are re-rendered from
5675 /// parts, and anything else — including a string a future producer forgot
5676 /// to genericize — collapses instead of passing through.
5677 #[test]
5678 fn generic_unavailable_detail_is_an_allowlist_not_a_passthrough() {
5679 use super::generic_unavailable_detail as detail;
5680
5681 assert_eq!(
5682 detail(Some("Hook timed out after 30s")),
5683 "hook timed out after 30s"
5684 );
5685 assert_eq!(
5686 detail(Some("hook process could not be started (NotFound)")),
5687 "hook process could not be started (NotFound)"
5688 );
5689 assert_eq!(
5690 detail(Some("Failed to wait for hook: os error 10")),
5691 "hook did not complete cleanly"
5692 );
5693 assert_eq!(
5694 detail(Some("hook could not be reaped after its timeout")),
5695 "hook did not complete cleanly"
5696 );
5697 assert_eq!(
5698 detail(Some("Failed to submit background hook: os error 11")),
5699 "hook could not be submitted"
5700 );
5701 assert_eq!(
5702 detail(Some("hook executor did not run")),
5703 "hook executor did not run"
5704 );
5705 assert_eq!(detail(None), "hook returned no verdict");
5706
5707 // A hypothetical future producer that leaks.
5708 let leaky = "spawn failed: /Users/someone/.aws/credentials --token=SECRET";
5709 let rendered = detail(Some(leaky));
5710 assert_eq!(rendered, "hook returned no verdict");
5711 assert!(!rendered.contains("SECRET"));
5712 assert!(!rendered.contains('/'));
5713
5714 // And a recognized prefix cannot be used to smuggle a tail along.
5715 let smuggled = detail(Some(
5716 "Hook timed out after 30s while running /usr/bin/leak --token=SECRET",
5717 ));
5718 assert_eq!(smuggled, "hook timed out after 30s");
5719 let smuggled = detail(Some(
5720 "hook process could not be started (NotFound) /usr/bin/leak",
5721 ));
5722 assert_eq!(smuggled, "hook process could not be started (NotFound)");
5723 }
5724
5725 /// The gate set the caller has to fail closed on if the executor is lost.
5726 #[cfg(unix)]
5727 #[test]
5728 fn matched_strict_gate_labels_names_only_gates_that_would_run() {
5729 use crate::hooks::{Hook, HookCondition, HookEvent, HooksConfig};
5730
5731 let strict_shell = {
5732 let mut hook = Hook::new(HookEvent::ToolCallBefore, "true")
5733 .with_name("shell-gate")
5734 .with_condition(HookCondition::ToolName {
5735 name: "exec_shell".into(),
5736 });
5737 hook.continue_on_error = false;
5738 hook
5739 };
5740 let strict_write = {
5741 let mut hook = Hook::new(HookEvent::ToolCallBefore, "true")
5742 .with_name("write-gate")
5743 .with_condition(HookCondition::ToolName {
5744 name: "write_file".into(),
5745 });
5746 hook.continue_on_error = false;
5747 hook
5748 };
5749 let lenient_shell = Hook::new(HookEvent::ToolCallBefore, "true").with_name("lenient");
5750 let background_strict = {
5751 let mut hook = Hook::new(HookEvent::ToolCallBefore, "true").with_name("bg-gate");
5752 hook.continue_on_error = false;
5753 hook.background = true;
5754 hook
5755 };
5756 let other_event = {
5757 let mut hook = Hook::new(HookEvent::ToolCallAfter, "true").with_name("after-gate");
5758 hook.continue_on_error = false;
5759 hook
5760 };
5761
5762 let executor = HookExecutor::new(
5763 HooksConfig {
5764 enabled: true,
5765 hooks: vec![
5766 strict_shell,
5767 strict_write,
5768 lenient_shell,
5769 background_strict,
5770 other_event,
5771 ],
5772 ..HooksConfig::default()
5773 },
5774 std::env::temp_dir(),
5775 );
5776
5777 let labels = executor.matched_strict_gate_labels(
5778 HookEvent::ToolCallBefore,
5779 &HookContext::new().with_tool_name("exec_shell"),
5780 );
5781 assert_eq!(labels, vec!["shell-gate".to_string()], "{labels:?}");
5782
5783 // Globally disabled hooks are not gates either.
5784 let disabled = HookExecutor::disabled();
5785 assert!(
5786 disabled
5787 .matched_strict_gate_labels(
5788 HookEvent::ToolCallBefore,
5789 &HookContext::new().with_tool_name("exec_shell"),
5790 )
5791 .is_empty()
5792 );
5793 }
5794
5795 /// The reap after a kill is bounded. This asserts the ordinary case is
5796 /// still confirmed dead and, more importantly, that the call returns —
5797 /// the regression it guards is a hang, not a wrong value.
5798 #[cfg(unix)]
5799 #[test]
5800 fn timed_out_hook_is_killed_and_reaped_within_the_bound() {
5801 use crate::hooks::{Hook, HookEvent, HooksConfig};
5802
5803 let hook = Hook::new(HookEvent::SessionStart, "sleep 30")
5804 .with_name("slow")
5805 .with_timeout(1);
5806 let executor = HookExecutor::new(
5807 HooksConfig {
5808 enabled: true,
5809 hooks: vec![hook],
5810 ..HooksConfig::default()
5811 },
5812 std::env::temp_dir(),
5813 );
5814
5815 let started = Instant::now();
5816 let results = executor.execute(HookEvent::SessionStart, &HookContext::new());
5817 let elapsed = started.elapsed();
5818
5819 assert_eq!(results.len(), 1);
5820 assert_eq!(
5821 results[0].error.as_deref(),
5822 Some("Hook timed out after 1s"),
5823 "the child was reaped, so the stronger claim is the honest one"
5824 );
5825 assert!(
5826 elapsed < Duration::from_secs(1) + super::HOOK_REAP_TIMEOUT + Duration::from_secs(5),
5827 "timeout path took {elapsed:?}"
5828 );
5829 }
5830
5831 #[test]
5832 fn tool_exit_code_env_var_survives_a_windows_crash_code() {
5833 // 0xC0000005 (access violation) does not fit in an `i32`. It used to
5834 // be dropped on the floor before the hook ever saw it.
5835 let env = HookContext::new()
5836 .with_tool_result("crashed", false, Some(3_221_225_477))
5837 .to_env_vars();
5838 assert_eq!(
5839 env.get("DEEPSEEK_TOOL_EXIT_CODE"),
5840 Some(&"3221225477".to_string())
5841 );
5842
5843 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
5844 let hook =
5845 Hook::new(HookEvent::ToolCallAfter, "true").with_condition(HookCondition::ExitCode {
5846 code: 3_221_225_477,
5847 });
5848 let context = HookContext::new()
5849 .with_tool_name("exec_shell")
5850 .with_tool_result("crashed", false, Some(3_221_225_477));
5851 assert!(executor.matches_condition(&hook, &context));
5852 }
5853
5854 /// 2026-08-04: the category map knew only retired tool names, so every
5855 /// live call fell through to `other` and a `tool_category` deny hook —
5856 /// the security control `docs/HOOKS.md` documents — silently never fired.
5857 #[test]
5858 fn tool_category_classifies_the_names_the_registry_actually_registers() {
5859 use super::tool_category_for;
5860
5861 // Anchor to the real catalog. Everything below this pins hardcoded
5862 // names, which would stay green through a tool rename while the gate
5863 // quietly reclassified the renamed tool. `DEFAULT_ACTIVE_NATIVE_TOOLS`
5864 // is the list the engine actually puts on the wire, so if a name here
5865 // stops being a name the product ships, this fails first.
5866 //
5867 // Note the fallback is "other", not "safe" — asserting against "safe"
5868 // here would never fire. This table is checked in both directions, so
5869 // a rename fails on the missing entry and a classifier change fails on
5870 // the mismatched category.
5871 const EXPECTED: &[(&str, &str)] = &[
5872 ("read", "safe"),
5873 ("write", "file_write"),
5874 ("edit", "file_write"),
5875 ("bash", "shell"),
5876 // The router itself touches nothing a hook needs to gate.
5877 ("agent", "other"),
5878 ("workflow", "other"),
5879 ("todo_write", "safe"),
5880 // Goal controls retain their existing hook classification when
5881 // promoted from deferred discovery to the eager catalog.
5882 ("create_goal", "other"),
5883 ("get_goal", "other"),
5884 ("update_goal", "other"),
5885 ];
5886 for name in crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS {
5887 let expected = EXPECTED.iter().find(|(n, _)| n == name).map(|(_, c)| *c);
5888 assert_eq!(
5889 Some(tool_category_for(name, None)),
5890 expected,
5891 "default-active tool {name:?} is not covered by this test's \
5892 table. It was renamed or added without updating the hook \
5893 gate's classifier, so the gate now sees a shipped tool it \
5894 does not recognise."
5895 );
5896 }
5897 for (name, _) in EXPECTED {
5898 assert!(
5899 crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS.contains(name),
5900 "{name:?} is pinned here but is no longer default-active; drop \
5901 it so this table keeps describing what actually ships."
5902 );
5903 }
5904
5905 // The shell surface.
5906 assert_eq!(tool_category_for("Bash", None), "shell");
5907 // Retained: shell.rs stamps this for the shell_env event.
5908 assert_eq!(tool_category_for("exec_shell", None), "shell");
5909 // Run executes commands, so it gates with shell rather than safe.
5910 assert_eq!(tool_category_for("Run", None), "shell");
5911
5912 // File is multi-action: the action decides.
5913 let read = r#"{"action":"read","path":"a.rs"}"#;
5914 let write = r#"{"action":"write","path":"a.rs","content":"x"}"#;
5915 assert_eq!(tool_category_for("File", Some(read)), "safe");
5916 assert_eq!(tool_category_for("File", Some(write)), "file_write");
5917 assert_eq!(
5918 tool_category_for("File", Some(r#"{"action":"edit"}"#)),
5919 "file_write"
5920 );
5921 assert_eq!(
5922 tool_category_for("File", Some(r#"{"action":"search_content"}"#)),
5923 "safe"
5924 );
5925
5926 // A gate that cannot see the action must assume the dangerous one.
5927 assert_eq!(tool_category_for("File", None), "file_write");
5928 assert_eq!(tool_category_for("File", Some("not json")), "file_write");
5929
5930 assert_eq!(tool_category_for("apply_patch", None), "file_write");
5931 assert_eq!(
5932 tool_category_for("Git", Some(r#"{"action":"log"}"#)),
5933 "safe"
5934 );
5935 assert_eq!(
5936 tool_category_for("Git", Some(r#"{"action":"commit_plan"}"#)),
5937 "safe"
5938 );
5939 assert_eq!(tool_category_for("web.run", None), "other");
5940 }
5941 }
5942
5942 lines RUST