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