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