返回 DeepSeek-TUI-2026
hooks.rs
根目录 / crates / tui / src / hooks.rs
1 //! Hooks system for `DeepSeek` CLI
2 //!
3 //! Provides lifecycle hooks that execute user-defined shell commands at:
4 //! - Session start/end
5 //! - Tool call before/after
6
7 //! - Mode changes
8 //! - Message submission
9 //! - Error events
10 //!
11 //! Configuration is done via `[[hooks.hooks]]` in config.toml.
12
13 // Note: anyhow is available if needed for future error handling
14 #[allow(unused_imports)]
15 use anyhow::{Context, Result};
16 use serde::{Deserialize, Serialize};
17 use std::collections::HashMap;
18 use std::io::Read;
19 use std::path::PathBuf;
20 use std::process::{Command, Stdio};
21 use std::time::{Duration, Instant};
22 use wait_timeout::ChildExt;
23
24 /// Events that can trigger hook execution
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26 #[serde(rename_all = "snake_case")]
27 pub enum HookEvent {
28 /// Triggered when a new session starts
29 SessionStart,
30 /// Triggered when a session ends (quit, Ctrl+C)
31 SessionEnd,
32 /// Triggered before a user message is sent to the LLM
33 MessageSubmit,
34 /// Triggered before a tool is executed
35 ToolCallBefore,
36 /// Triggered after a tool completes (success or failure)
37 ToolCallAfter,
38 /// Triggered when the user changes modes (Plan, Agent, Yolo)
39 ModeChange,
40 /// Triggered when an error occurs
41 OnError,
42 /// Triggered immediately before each `exec_shell` invocation. The hook's
43 /// stdout is parsed as `KEY=VALUE\n` lines and merged on top of the
44 /// shell command's environment — useful for ephemeral credentials,
45 /// per-skill PATH adjustments, or short-lived tokens (#456). Hooks that
46 /// fail or time out are logged but do *not* abort the shell call; they
47 /// simply contribute no env vars.
48 ShellEnv,
49 }
50
51 impl HookEvent {
52 /// Get string representation for environment variable
53 #[allow(dead_code)] // Used in tests and future hook dispatch
54 pub fn as_str(self) -> &'static str {
55 match self {
56 HookEvent::SessionStart => "session_start",
57 HookEvent::SessionEnd => "session_end",
58 HookEvent::MessageSubmit => "message_submit",
59 HookEvent::ToolCallBefore => "tool_call_before",
60 HookEvent::ToolCallAfter => "tool_call_after",
61 HookEvent::ModeChange => "mode_change",
62 HookEvent::OnError => "on_error",
63 HookEvent::ShellEnv => "shell_env",
64 }
65 }
66 }
67
68 /// Condition for when a hook should run
69 #[derive(Debug, Clone, Serialize, Deserialize)]
70 #[serde(tag = "type", rename_all = "snake_case")]
71 #[derive(Default)]
72 pub enum HookCondition {
73 /// Always run this hook
74 #[default]
75 Always,
76 /// Only run for specific tool names
77 ToolName {
78 /// Tool name to match (e.g., "`exec_shell`", "`write_file`")
79 name: String,
80 },
81 /// Only run for specific tool categories
82 ToolCategory {
83 /// Category: "safe", "`file_write`", "shell"
84 category: String,
85 },
86 /// Only run in specific modes
87 Mode {
88 /// Mode: "plan", "agent", "yolo"
89 mode: String,
90 },
91 /// Only run when exit code matches (for `ToolCallAfter`)
92 ExitCode {
93 /// Exit code to match
94 code: i32,
95 },
96 /// Combine multiple conditions with AND
97 All { conditions: Vec<HookCondition> },
98 /// Combine multiple conditions with OR
99 Any { conditions: Vec<HookCondition> },
100 }
101
102 /// A single hook definition
103 #[derive(Debug, Clone, Serialize, Deserialize)]
104 pub struct Hook {
105 /// The event that triggers this hook
106 pub event: HookEvent,
107
108 /// Shell command to execute (platform shell: `sh -c` on Unix, `cmd /C` on Windows)
109 pub command: String,
110
111 /// Optional condition for when this hook should run
112 #[serde(default)]
113 pub condition: Option<HookCondition>,
114
115 /// Timeout in seconds (default: 30)
116 #[serde(default = "default_timeout")]
117 pub timeout_secs: u64,
118
119 /// Run in background (don't wait for completion)
120 #[serde(default)]
121 pub background: bool,
122
123 /// Continue if this hook fails (default: true)
124 #[serde(default = "default_continue_on_error")]
125 pub continue_on_error: bool,
126
127 /// Optional name for logging/debugging
128 #[serde(default)]
129 pub name: Option<String>,
130 }
131
132 fn default_timeout() -> u64 {
133 30
134 }
135 fn default_continue_on_error() -> bool {
136 true
137 }
138
139 impl Hook {
140 /// Create a new hook with minimal configuration
141 #[allow(dead_code)] // Public builder API, used in tests
142 pub fn new(event: HookEvent, command: &str) -> Self {
143 Self {
144 event,
145 command: command.to_string(),
146 condition: None,
147 timeout_secs: 30,
148 background: false,
149 continue_on_error: true,
150 name: None,
151 }
152 }
153
154 /// Builder: set condition
155 #[allow(dead_code)] // Public builder API, used in tests
156 pub fn with_condition(mut self, condition: HookCondition) -> Self {
157 self.condition = Some(condition);
158 self
159 }
160
161 /// Builder: set timeout
162 #[allow(dead_code)] // Public builder API, used in tests
163 pub fn with_timeout(mut self, secs: u64) -> Self {
164 self.timeout_secs = secs;
165 self
166 }
167
168 /// Builder: run in background
169 #[allow(dead_code)] // Public builder API, used in tests
170 pub fn background(mut self) -> Self {
171 self.background = true;
172 self
173 }
174
175 /// Builder: set name
176 #[allow(dead_code)] // Public builder API, used in tests
177 pub fn with_name(mut self, name: &str) -> Self {
178 self.name = Some(name.to_string());
179 self
180 }
181 }
182
183 /// Configuration for hooks (loaded from config.toml)
184 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
185 pub struct HooksConfig {
186 /// List of hooks to execute
187 #[serde(default)]
188 pub hooks: Vec<Hook>,
189
190 /// Global enable/disable for all hooks
191 #[serde(default = "default_enabled")]
192 pub enabled: bool,
193
194 /// Global timeout override (applies if hook doesn't specify one)
195 #[serde(default)]
196 pub default_timeout_secs: Option<u64>,
197
198 /// Working directory for hook execution (default: workspace)
199 #[serde(default)]
200 pub working_dir: Option<PathBuf>,
201 }
202
203 fn default_enabled() -> bool {
204 true
205 }
206
207 impl HooksConfig {
208 /// Get hooks for a specific event
209 pub fn hooks_for_event(&self, event: HookEvent) -> Vec<&Hook> {
210 if !self.enabled {
211 return Vec::new();
212 }
213 self.hooks.iter().filter(|h| h.event == event).collect()
214 }
215
216 /// Check if hooks are configured and enabled
217 #[allow(dead_code)] // Public API for hook system consumers
218 pub fn has_hooks(&self) -> bool {
219 self.enabled && !self.hooks.is_empty()
220 }
221 }
222
223 /// Context passed to hooks via environment variables
224 #[derive(Debug, Clone, Default)]
225 pub struct HookContext {
226 /// Tool name (for ToolCallBefore/After)
227 pub tool_name: Option<String>,
228 /// Tool arguments as JSON string
229 pub tool_args: Option<String>,
230 /// Tool result output (truncated)
231 pub tool_result: Option<String>,
232 /// Tool exit code if applicable
233 pub tool_exit_code: Option<i32>,
234 /// Whether tool succeeded
235 pub tool_success: Option<bool>,
236 /// Current mode
237 pub mode: Option<String>,
238 /// Previous mode (for `ModeChange`)
239 pub previous_mode: Option<String>,
240 /// Session ID
241 pub session_id: Option<String>,
242 /// User message content
243 pub message: Option<String>,
244 /// Error message (for `OnError`)
245 pub error_message: Option<String>,
246 /// Workspace path
247 pub workspace: Option<PathBuf>,
248 /// Current model name
249 pub model: Option<String>,
250 /// Total tokens used
251 pub total_tokens: Option<u32>,
252 /// Session cost in USD
253 pub session_cost: Option<f64>,
254 }
255
256 impl HookContext {
257 pub fn new() -> Self {
258 Self::default()
259 }
260
261 #[allow(dead_code)] // Public builder API, used in tests
262 pub fn with_tool_name(mut self, name: &str) -> Self {
263 self.tool_name = Some(name.to_string());
264 self
265 }
266
267 #[allow(dead_code)] // Public builder API
268 pub fn with_tool_args(mut self, args: &serde_json::Value) -> Self {
269 self.tool_args = Some(args.to_string());
270 self
271 }
272
273 #[allow(dead_code)] // Public builder API
274 pub fn with_tool_result(mut self, result: &str, success: bool, exit_code: Option<i32>) -> Self {
275 self.tool_result = Some(result.to_string());
276 self.tool_success = Some(success);
277 self.tool_exit_code = exit_code;
278 self
279 }
280
281 #[allow(dead_code)] // Public builder API, used in tests
282 pub fn with_mode(mut self, mode: &str) -> Self {
283 self.mode = Some(mode.to_string());
284 self
285 }
286
287 pub fn with_previous_mode(mut self, mode: &str) -> Self {
288 self.previous_mode = Some(mode.to_string());
289 self
290 }
291
292 #[allow(dead_code)] // Public builder API, used in tests
293 pub fn with_workspace(mut self, path: PathBuf) -> Self {
294 self.workspace = Some(path);
295 self
296 }
297
298 pub fn with_model(mut self, model: &str) -> Self {
299 self.model = Some(model.to_string());
300 self
301 }
302
303 pub fn with_session_id(mut self, session_id: &str) -> Self {
304 self.session_id = Some(session_id.to_string());
305 self
306 }
307
308 #[allow(dead_code)] // Public builder API
309 pub fn with_message(mut self, message: &str) -> Self {
310 self.message = Some(message.to_string());
311 self
312 }
313
314 #[allow(dead_code)] // Public builder API
315 pub fn with_error(mut self, error: &str) -> Self {
316 self.error_message = Some(error.to_string());
317 self
318 }
319
320 pub fn with_tokens(mut self, tokens: u32) -> Self {
321 self.total_tokens = Some(tokens);
322 self
323 }
324
325 #[allow(dead_code)] // Public builder API
326 pub fn with_cost(mut self, cost: f64) -> Self {
327 self.session_cost = Some(cost);
328 self
329 }
330
331 /// Convert to environment variables
332 pub fn to_env_vars(&self) -> HashMap<String, String> {
333 let mut env = HashMap::new();
334
335 if let Some(ref name) = self.tool_name {
336 env.insert("DEEPSEEK_TOOL_NAME".to_string(), name.clone());
337 }
338 if let Some(ref args) = self.tool_args {
339 env.insert("DEEPSEEK_TOOL_ARGS".to_string(), args.clone());
340 }
341 if let Some(ref result) = self.tool_result {
342 // Truncate result to 10KB to avoid environment variable size limits
343 let truncated = if result.len() > 10000 {
344 let safe_end = result
345 .char_indices()
346 .take_while(|(i, _)| *i < 10000)
347 .last()
348 .map(|(i, c)| i + c.len_utf8())
349 .unwrap_or(0);
350 format!("{}...[truncated]", &result[..safe_end])
351 } else {
352 result.clone()
353 };
354 env.insert("DEEPSEEK_TOOL_RESULT".to_string(), truncated);
355 }
356 if let Some(code) = self.tool_exit_code {
357 env.insert("DEEPSEEK_TOOL_EXIT_CODE".to_string(), code.to_string());
358 }
359 if let Some(success) = self.tool_success {
360 env.insert("DEEPSEEK_TOOL_SUCCESS".to_string(), success.to_string());
361 }
362 if let Some(ref mode) = self.mode {
363 env.insert("DEEPSEEK_MODE".to_string(), mode.clone());
364 }
365 if let Some(ref prev) = self.previous_mode {
366 env.insert("DEEPSEEK_PREVIOUS_MODE".to_string(), prev.clone());
367 }
368 if let Some(ref session_id) = self.session_id {
369 env.insert("DEEPSEEK_SESSION_ID".to_string(), session_id.clone());
370 }
371 if let Some(ref message) = self.message {
372 // Truncate message to prevent env var issues
373 let truncated = if message.len() > 5000 {
374 let safe_end = message
375 .char_indices()
376 .take_while(|(i, _)| *i < 5000)
377 .last()
378 .map(|(i, c)| i + c.len_utf8())
379 .unwrap_or(0);
380 format!("{}...[truncated]", &message[..safe_end])
381 } else {
382 message.clone()
383 };
384 env.insert("DEEPSEEK_MESSAGE".to_string(), truncated);
385 }
386 if let Some(ref error) = self.error_message {
387 env.insert("DEEPSEEK_ERROR".to_string(), error.clone());
388 }
389 if let Some(ref ws) = self.workspace {
390 env.insert("DEEPSEEK_WORKSPACE".to_string(), ws.display().to_string());
391 }
392 if let Some(ref model) = self.model {
393 env.insert("DEEPSEEK_MODEL".to_string(), model.clone());
394 }
395 if let Some(tokens) = self.total_tokens {
396 env.insert("DEEPSEEK_TOTAL_TOKENS".to_string(), tokens.to_string());
397 }
398 if let Some(cost) = self.session_cost {
399 env.insert("DEEPSEEK_SESSION_COST".to_string(), format!("{cost:.6}"));
400 }
401
402 env
403 }
404 }
405
406 /// Result of a hook execution
407 #[derive(Debug, Clone)]
408 #[allow(dead_code)] // Fields are part of public API for hook consumers
409 pub struct HookResult {
410 /// Hook name (if specified)
411 pub name: Option<String>,
412 /// Whether the hook succeeded
413 pub success: bool,
414 /// Exit code from the hook command
415 pub exit_code: Option<i32>,
416 /// Standard output
417 pub stdout: String,
418 /// Standard error
419 pub stderr: String,
420 /// Time taken to execute
421 pub duration: Duration,
422 /// Error message if execution failed
423 pub error: Option<String>,
424 }
425
426 /// Executor for running hooks
427 #[derive(Debug, Clone)]
428 pub struct HookExecutor {
429 config: HooksConfig,
430 default_working_dir: PathBuf,
431 session_id: String,
432 }
433
434 impl HookExecutor {
435 fn build_shell_command(command: &str) -> Command {
436 #[cfg(windows)]
437 {
438 let mut cmd = Command::new("cmd");
439 cmd.arg("/C").arg(command);
440 cmd
441 }
442 #[cfg(not(windows))]
443 {
444 let mut cmd = Command::new("sh");
445 cmd.arg("-c").arg(command);
446 cmd
447 }
448 }
449
450 /// Create a new `HookExecutor` with configuration
451 pub fn new(config: HooksConfig, default_working_dir: PathBuf) -> Self {
452 // Generate a session ID
453 let session_id = format!("sess_{}", &uuid::Uuid::new_v4().to_string()[..8]);
454 Self {
455 config,
456 default_working_dir,
457 session_id,
458 }
459 }
460
461 /// Create a disabled `HookExecutor` (no hooks will run)
462 #[allow(dead_code)] // Used in tests and as convenience constructor
463 pub fn disabled() -> Self {
464 Self {
465 config: HooksConfig {
466 enabled: false,
467 ..Default::default()
468 },
469 default_working_dir: PathBuf::from("."),
470 session_id: String::new(),
471 }
472 }
473
474 /// Check if hooks are enabled
475 #[allow(dead_code)] // Public API for hook system consumers
476 pub fn is_enabled(&self) -> bool {
477 self.config.enabled
478 }
479
480 /// Get the session ID
481 /// Read-only access to the underlying configuration. Used by
482 /// `/hooks` (#460 read-only MVP) so the user can list configured
483 /// hooks without reaching for `cat ~/.deepseek/config.toml`.
484 pub fn config(&self) -> &HooksConfig {
485 &self.config
486 }
487
488 pub fn session_id(&self) -> &str {
489 &self.session_id
490 }
491
492 /// Cheap pre-check: are there any enabled hooks for this event?
493 /// Lets call sites avoid building a [`HookContext`] (which allocates
494 /// for `workspace`, `model`, `session_id`, …) on every tool call
495 /// when the user hasn't configured any hooks. The cost matters
496 /// because `ToolCallBefore` / `ToolCallAfter` fire from
497 /// `tool_routing.rs` on every tool dispatch (#455).
498 #[must_use]
499 pub fn has_hooks_for_event(&self, event: HookEvent) -> bool {
500 self.config.enabled && self.config.hooks.iter().any(|h| h.event == event)
501 }
502
503 /// Run every `ShellEnv` hook for this context and merge their stdout
504 /// (`KEY=VALUE\n` lines) into a single env-var map. Used by the
505 /// `exec_shell` tool to inject ephemeral credentials, per-skill PATH
506 /// adjustments, etc. (#456). Failures don't abort the shell call —
507 /// the hook simply contributes no vars and a `tracing::warn!` lands.
508 ///
509 /// Each successful hook's keys (NOT values) are written to the audit
510 /// log so a session can be reconciled later without leaking the
511 /// secret material itself.
512 pub fn collect_shell_env(&self, context: &HookContext) -> HashMap<String, String> {
513 let mut merged: HashMap<String, String> = HashMap::new();
514 if !self.config.enabled {
515 return merged;
516 }
517 let hooks = self.config.hooks_for_event(HookEvent::ShellEnv);
518 if hooks.is_empty() {
519 return merged;
520 }
521 let env_vars = context.to_env_vars();
522 for hook in hooks {
523 if !self.matches_condition(hook, context) {
524 continue;
525 }
526 // ShellEnv hooks must be synchronous — their stdout is the contract.
527 let result = self.execute_sync(hook, &env_vars);
528 if !result.success {
529 tracing::warn!(
530 target: "hooks",
531 hook = result.name.as_deref().unwrap_or("(unnamed)"),
532 event = "shell_env",
533 exit_code = ?result.exit_code,
534 error = result.error.as_deref().unwrap_or(""),
535 "shell_env hook failed; contributing no env vars"
536 );
537 continue;
538 }
539 let parsed = parse_env_lines(&result.stdout);
540 if parsed.is_empty() {
541 continue;
542 }
543 // Audit-log the *keys* — never the values.
544 crate::audit::log_sensitive_event(
545 "shell_env_hook",
546 serde_json::json!({
547 "hook": result.name,
548 "tool": context.tool_name,
549 "keys": parsed.keys().cloned().collect::<Vec<_>>(),
550 }),
551 );
552 // Later hooks override earlier ones. Documented behavior.
553 merged.extend(parsed);
554 }
555 merged
556 }
557
558 /// Execute all hooks for an event
559 pub fn execute(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
560 if !self.config.enabled {
561 return Vec::new();
562 }
563
564 let hooks = self.config.hooks_for_event(event);
565 if hooks.is_empty() {
566 // Fast path: no hooks for this event → skip the
567 // `context.to_env_vars()` HashMap allocation. With
568 // `tool_call_before` / `tool_call_after` firing per-tool
569 // (#455) this allocation would otherwise happen on every
570 // tool dispatch even for users with zero hooks configured.
571 return Vec::new();
572 }
573 let env_vars = context.to_env_vars();
574 let mut results = Vec::new();
575
576 for hook in hooks {
577 if !self.matches_condition(hook, context) {
578 continue;
579 }
580
581 let result = if hook.background {
582 self.execute_background(hook, &env_vars)
583 } else {
584 self.execute_sync(hook, &env_vars)
585 };
586
587 // Log failures via tracing so operators tailing
588 // `deepseek` with `RUST_LOG=warn` can see hook errors
589 // without instrumenting each call site. Successful runs
590 // log nothing (would be too noisy on per-tool events).
591 if !result.success {
592 let label = result.name.as_deref().unwrap_or("(unnamed)");
593 tracing::warn!(
594 target: "hooks",
595 hook = label,
596 event = event.as_str(),
597 exit_code = ?result.exit_code,
598 duration_ms = result.duration.as_millis() as u64,
599 error = result.error.as_deref().unwrap_or(""),
600 stderr_head = %result.stderr.lines().next().unwrap_or(""),
601 "hook failed"
602 );
603 }
604
605 let should_continue = result.success || hook.continue_on_error;
606 results.push(result);
607
608 if !should_continue {
609 break;
610 }
611 }
612
613 results
614 }
615
616 /// Check if a hook's condition matches the context
617 #[allow(clippy::only_used_in_recursion)]
618 fn matches_condition(&self, hook: &Hook, context: &HookContext) -> bool {
619 match &hook.condition {
620 None | Some(HookCondition::Always) => true,
621 Some(HookCondition::ToolName { name }) => {
622 context.tool_name.as_ref().is_some_and(|n| n == name)
623 }
624 Some(HookCondition::ToolCategory { category }) => {
625 // Map tool names to categories
626 let tool_category = context.tool_name.as_ref().map(|name| match name.as_str() {
627 "exec_shell" => "shell",
628 "write_file" | "edit_file" | "apply_patch" => "file_write",
629 "read_file" | "list_dir" | "grep_files" => "safe",
630 _ => "other",
631 });
632 tool_category.is_some_and(|c| c == category.as_str())
633 }
634 Some(HookCondition::Mode { mode }) => context
635 .mode
636 .as_ref()
637 .is_some_and(|m| m.to_lowercase() == mode.to_lowercase()),
638 Some(HookCondition::ExitCode { code }) => context.tool_exit_code == Some(*code),
639 Some(HookCondition::All { conditions }) => conditions.iter().all(|c| {
640 self.matches_condition(
641 &Hook {
642 condition: Some(c.clone()),
643 ..hook.clone()
644 },
645 context,
646 )
647 }),
648 Some(HookCondition::Any { conditions }) => conditions.iter().any(|c| {
649 self.matches_condition(
650 &Hook {
651 condition: Some(c.clone()),
652 ..hook.clone()
653 },
654 context,
655 )
656 }),
657 }
658 }
659
660 /// Execute a hook synchronously
661 fn execute_sync(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
662 let started = Instant::now();
663 let working_dir = self
664 .config
665 .working_dir
666 .clone()
667 .unwrap_or_else(|| self.default_working_dir.clone());
668
669 let timeout_secs = self
670 .config
671 .default_timeout_secs
672 .unwrap_or(hook.timeout_secs);
673 let timeout = Duration::from_secs(timeout_secs);
674
675 let mut child = match Self::build_shell_command(&hook.command)
676 .current_dir(&working_dir)
677 .envs(env_vars)
678 .stdout(Stdio::piped())
679 .stderr(Stdio::piped())
680 .spawn()
681 {
682 Ok(child) => child,
683 Err(e) => {
684 return HookResult {
685 name: hook.name.clone(),
686 success: false,
687 exit_code: None,
688 stdout: String::new(),
689 stderr: String::new(),
690 duration: started.elapsed(),
691 error: Some(format!("Failed to spawn hook: {e}")),
692 };
693 }
694 };
695
696 fn read_pipe(mut pipe: impl Read) -> String {
697 let mut buf = String::new();
698 let _ = pipe.read_to_string(&mut buf);
699 buf
700 }
701
702 match child.wait_timeout(timeout) {
703 Ok(Some(status)) => HookResult {
704 name: hook.name.clone(),
705 success: status.success(),
706 exit_code: status.code(),
707 stdout: child.stdout.take().map(read_pipe).unwrap_or_default(),
708 stderr: child.stderr.take().map(read_pipe).unwrap_or_default(),
709 duration: started.elapsed(),
710 error: None,
711 },
712 Ok(None) => {
713 let _ = child.kill();
714 let _ = child.wait();
715 HookResult {
716 name: hook.name.clone(),
717 success: false,
718 exit_code: None,
719 stdout: String::new(),
720 stderr: String::new(),
721 duration: started.elapsed(),
722 error: Some(format!("Hook timed out after {}s", timeout_secs)),
723 }
724 }
725 Err(e) => HookResult {
726 name: hook.name.clone(),
727 success: false,
728 exit_code: None,
729 stdout: String::new(),
730 stderr: String::new(),
731 duration: started.elapsed(),
732 error: Some(format!("Failed to wait for hook: {e}")),
733 },
734 }
735 }
736
737 /// Execute a hook in the background (non-blocking)
738 fn execute_background(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
739 let started = Instant::now();
740 let working_dir = self
741 .config
742 .working_dir
743 .clone()
744 .unwrap_or_else(|| self.default_working_dir.clone());
745
746 let cmd = hook.command.clone();
747 let env = env_vars.clone();
748 let wd = working_dir.clone();
749
750 // Spawn in a detached thread
751 std::thread::spawn(move || {
752 let _ = HookExecutor::build_shell_command(&cmd)
753 .current_dir(&wd)
754 .envs(&env)
755 .output();
756 });
757
758 // Return immediately with success (background execution is fire-and-forget)
759 HookResult {
760 name: hook.name.clone(),
761 success: true,
762 exit_code: None,
763 stdout: String::new(),
764 stderr: String::new(),
765 duration: started.elapsed(),
766 error: None,
767 }
768 }
769 }
770
771 /// Parse `KEY=VALUE\n` lines from a `shell_env` hook's stdout into a map.
772 ///
773 /// Tolerated: blank lines, leading whitespace, `#` comment lines (ignored),
774 /// `export KEY=VALUE` (the `export ` prefix is dropped), surrounding quotes
775 /// on the value. Lines without `=` are silently dropped — easier than
776 /// failing the whole hook for one stray line of human-friendly output.
777 /// Values are otherwise taken verbatim; we don't run them through a shell
778 /// for variable expansion to avoid surprises.
779 fn parse_env_lines(stdout: &str) -> HashMap<String, String> {
780 let mut out = HashMap::new();
781 for raw in stdout.lines() {
782 let line = raw.trim();
783 if line.is_empty() || line.starts_with('#') {
784 continue;
785 }
786 let line = line.strip_prefix("export ").unwrap_or(line);
787 let Some((key, value)) = line.split_once('=') else {
788 continue;
789 };
790 let key = key.trim();
791 if key.is_empty() {
792 continue;
793 }
794 let value = value.trim();
795 let stripped = value
796 .strip_prefix('"')
797 .and_then(|v| v.strip_suffix('"'))
798 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
799 .unwrap_or(value);
800 out.insert(key.to_string(), stripped.to_string());
801 }
802 out
803 }
804
805 // === Unit Tests ===
806
807 #[cfg(test)]
808 mod tests {
809 use super::*;
810 use std::collections::HashMap;
811 use std::path::PathBuf;
812
813 /// #456 — `parse_env_lines` covers the formats users actually emit from
814 /// shell hooks: bare `KEY=VAL`, `export KEY=VAL`, quoted values, comments,
815 /// blank lines. Lines without `=` are dropped; values are taken verbatim
816 /// (no shell expansion).
817 #[test]
818 fn parse_env_lines_handles_realistic_hook_output() {
819 let stdout = r#"
820 # Aux comment line, ignored
821 AWS_ACCESS_KEY_ID=AKIAEXAMPLE
822 export GITHUB_TOKEN=ghp_examplevalue
823 QUOTED="value with spaces"
824 SINGLE='also valid'
825
826 = empty key dropped
827 NOEQUAL line dropped
828 "#;
829 let parsed = super::parse_env_lines(stdout);
830 assert_eq!(
831 parsed.get("AWS_ACCESS_KEY_ID"),
832 Some(&"AKIAEXAMPLE".to_string())
833 );
834 assert_eq!(
835 parsed.get("GITHUB_TOKEN"),
836 Some(&"ghp_examplevalue".to_string())
837 );
838 assert_eq!(parsed.get("QUOTED"), Some(&"value with spaces".to_string()));
839 assert_eq!(parsed.get("SINGLE"), Some(&"also valid".to_string()));
840 assert!(!parsed.contains_key(""));
841 assert!(!parsed.contains_key("NOEQUAL line dropped"));
842 // 4 valid entries above; nothing else.
843 assert_eq!(parsed.len(), 4);
844 }
845
846 /// #456 — empty stdout (or only blank/comments) yields an empty map.
847 #[test]
848 fn parse_env_lines_empty_when_no_assignments() {
849 let parsed = super::parse_env_lines("# nothing\n\n \n");
850 assert!(parsed.is_empty());
851 }
852
853 #[test]
854 fn test_hook_event_as_str() {
855 assert_eq!(HookEvent::SessionStart.as_str(), "session_start");
856 assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after");
857 assert_eq!(HookEvent::ModeChange.as_str(), "mode_change");
858 }
859
860 #[test]
861 fn test_hook_context_to_env_vars() {
862 let ctx = HookContext::new()
863 .with_tool_name("exec_shell")
864 .with_mode("agent")
865 .with_workspace(PathBuf::from("/tmp"));
866
867 let env = ctx.to_env_vars();
868
869 assert_eq!(
870 env.get("DEEPSEEK_TOOL_NAME"),
871 Some(&"exec_shell".to_string())
872 );
873 assert_eq!(env.get("DEEPSEEK_MODE"), Some(&"agent".to_string()));
874 assert_eq!(env.get("DEEPSEEK_WORKSPACE"), Some(&"/tmp".to_string()));
875 }
876
877 #[test]
878 fn test_hook_condition_always() {
879 let hook = Hook::new(HookEvent::SessionStart, "echo test");
880 let executor = HookExecutor::disabled();
881 let context = HookContext::new();
882
883 assert!(executor.matches_condition(&hook, &context));
884 }
885
886 #[test]
887 fn test_hook_condition_tool_name() {
888 let hook = Hook::new(HookEvent::ToolCallBefore, "echo test").with_condition(
889 HookCondition::ToolName {
890 name: "exec_shell".to_string(),
891 },
892 );
893
894 let executor = HookExecutor::disabled();
895
896 let context_match = HookContext::new().with_tool_name("exec_shell");
897 let context_no_match = HookContext::new().with_tool_name("write_file");
898
899 assert!(executor.matches_condition(&hook, &context_match));
900 assert!(!executor.matches_condition(&hook, &context_no_match));
901 }
902
903 #[test]
904 fn test_hook_condition_mode() {
905 let hook =
906 Hook::new(HookEvent::ModeChange, "echo test").with_condition(HookCondition::Mode {
907 mode: "agent".to_string(),
908 });
909
910 let executor = HookExecutor::disabled();
911
912 let context_match = HookContext::new().with_mode("AGENT"); // Case insensitive
913 let context_no_match = HookContext::new().with_mode("normal");
914
915 assert!(executor.matches_condition(&hook, &context_match));
916 assert!(!executor.matches_condition(&hook, &context_no_match));
917 }
918
919 #[test]
920 fn test_hooks_config_for_event() {
921 let config = HooksConfig {
922 enabled: true,
923 hooks: vec![
924 Hook::new(HookEvent::SessionStart, "echo start"),
925 Hook::new(HookEvent::SessionEnd, "echo end"),
926 Hook::new(HookEvent::SessionStart, "echo start2"),
927 ],
928 ..Default::default()
929 };
930
931 let start_hooks = config.hooks_for_event(HookEvent::SessionStart);
932 assert_eq!(start_hooks.len(), 2);
933
934 let end_hooks = config.hooks_for_event(HookEvent::SessionEnd);
935 assert_eq!(end_hooks.len(), 1);
936 }
937
938 #[test]
939 fn test_hooks_config_disabled() {
940 let config = HooksConfig {
941 enabled: false,
942 hooks: vec![Hook::new(HookEvent::SessionStart, "echo start")],
943 ..Default::default()
944 };
945
946 let hooks = config.hooks_for_event(HookEvent::SessionStart);
947 assert!(hooks.is_empty());
948 }
949
950 #[test]
951 fn test_hook_builder() {
952 let hook = Hook::new(HookEvent::ToolCallAfter, "notify.sh")
953 .with_name("notify_tool")
954 .with_timeout(60)
955 .background()
956 .with_condition(HookCondition::ToolCategory {
957 category: "shell".to_string(),
958 });
959
960 assert_eq!(hook.name, Some("notify_tool".to_string()));
961 assert_eq!(hook.timeout_secs, 60);
962 assert!(hook.background);
963 assert!(matches!(
964 hook.condition,
965 Some(HookCondition::ToolCategory { .. })
966 ));
967 }
968
969 #[test]
970 fn test_hook_timeout_enforced() {
971 let command = if cfg!(windows) {
972 "ping -n 3 127.0.0.1 > nul"
973 } else {
974 "sleep 2"
975 };
976 let hook = Hook::new(HookEvent::SessionStart, command).with_timeout(1);
977 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
978 let env_vars = HashMap::new();
979
980 let result = executor.execute_sync(&hook, &env_vars);
981 assert!(!result.success);
982 assert!(
983 result
984 .error
985 .as_ref()
986 .is_some_and(|e| e.contains("timed out"))
987 );
988 }
989
990 #[test]
991 fn test_executor_session_id() {
992 let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
993
994 assert!(executor.session_id().starts_with("sess_"));
995 assert_eq!(executor.session_id().len(), 13); // "sess_" + 8 chars
996 }
997
998 #[test]
999 fn has_hooks_for_event_fast_path_returns_false_for_empty_config() {
1000 let executor = HookExecutor::disabled();
1001 // No hooks configured AT ALL — every event is a fast skip.
1002 for event in [
1003 HookEvent::SessionStart,
1004 HookEvent::SessionEnd,
1005 HookEvent::MessageSubmit,
1006 HookEvent::ToolCallBefore,
1007 HookEvent::ToolCallAfter,
1008 HookEvent::ModeChange,
1009 HookEvent::OnError,
1010 ] {
1011 assert!(
1012 !executor.has_hooks_for_event(event),
1013 "empty config must short-circuit for {event:?}"
1014 );
1015 }
1016 }
1017
1018 #[test]
1019 fn has_hooks_for_event_returns_false_when_globally_disabled() {
1020 let config = HooksConfig {
1021 enabled: false,
1022 hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo blocked")],
1023 ..HooksConfig::default()
1024 };
1025 let executor = HookExecutor::new(config, PathBuf::from("."));
1026 assert!(
1027 !executor.has_hooks_for_event(HookEvent::ToolCallBefore),
1028 "globally-disabled hooks must report no fires even when one is configured"
1029 );
1030 }
1031
1032 #[test]
1033 fn has_hooks_for_event_distinguishes_event_types() {
1034 let config = HooksConfig {
1035 enabled: true,
1036 hooks: vec![
1037 Hook::new(HookEvent::SessionStart, "echo start"),
1038 Hook::new(HookEvent::ToolCallBefore, "echo before"),
1039 ],
1040 ..HooksConfig::default()
1041 };
1042 let executor = HookExecutor::new(config, PathBuf::from("."));
1043 // Configured events return true.
1044 assert!(executor.has_hooks_for_event(HookEvent::SessionStart));
1045 assert!(executor.has_hooks_for_event(HookEvent::ToolCallBefore));
1046 // Unconfigured events return false even when other events are present.
1047 assert!(!executor.has_hooks_for_event(HookEvent::ToolCallAfter));
1048 assert!(!executor.has_hooks_for_event(HookEvent::OnError));
1049 assert!(!executor.has_hooks_for_event(HookEvent::ModeChange));
1050 }
1051 }
1052
1052 lines RUST