1use std::path::PathBuf;
2use std::process::Stdio;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use serde_json::Value;
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tokio::process::Command;
10use tokio::sync::Mutex;
11use tokio::time;
12
13use crate::config::{HookCommandConfig, HooksConfig};
14use crate::exec::events::{CompactionMode, CompactionTrigger};
15use crate::permissions::PermissionRequest;
16use crate::utils::dot_config::load_lifecycle_hook_approval;
17
18use crate::hooks::lifecycle::compiled::CompiledLifecycleHooks;
19use crate::hooks::lifecycle::interpret::{
20 HookCommandResult, interpret_permission_request, interpret_post_tool, interpret_pre_tool, interpret_session_end,
21 interpret_session_start, interpret_stop, interpret_user_prompt,
22};
23use crate::hooks::lifecycle::types::{
24 HookMessage, NotificationHookType, PermissionRequestHookOutcome, PostToolHookOutcome, PreCompactHookOutcome,
25 PreToolHookDecision, PreToolHookOutcome, SessionEndReason, SessionStartHookOutcome, SessionStartTrigger,
26 StopHookOutcome, UserPromptHookOutcome,
27};
28#[cfg(test)]
29use crate::hooks::lifecycle::utils::generate_session_id;
30use crate::hooks::lifecycle::utils::path_to_string;
31
32const DEFAULT_TIMEOUT_SECS: u64 = 60;
33
34mod payloads;
35
36#[derive(Clone)]
37pub struct LifecycleHookEngine {
38 inner: Arc<LifecycleHookInner>,
39}
40
41impl LifecycleHookEngine {
42 #[cfg(test)]
47 pub fn new(workspace: PathBuf, config: &HooksConfig, trigger: SessionStartTrigger) -> Result<Option<Self>> {
48 Self::new_with_session(workspace, config, trigger, generate_session_id())
49 }
50
51 #[cfg(test)]
53 pub fn new_with_session(
54 workspace: PathBuf,
55 config: &HooksConfig,
56 trigger: SessionStartTrigger,
57 session_id: impl Into<String>,
58 ) -> Result<Option<Self>> {
59 Self::new_with_session_gated(workspace, config, trigger, session_id, false)
60 }
61
62 pub fn new_with_session_gated(
73 workspace: PathBuf,
74 config: &HooksConfig,
75 trigger: SessionStartTrigger,
76 session_id: impl Into<String>,
77 workspace_gated: bool,
78 ) -> Result<Option<Self>> {
79 if config.lifecycle.is_empty() {
80 return Ok(None);
81 }
82
83 let compiled = CompiledLifecycleHooks::from_config(&config.lifecycle)?;
84 if compiled.is_empty() {
85 return Ok(None);
86 }
87
88 let command_digest = commands_digest(&compiled);
89
90 Ok(Some(Self {
91 inner: Arc::new(LifecycleHookInner {
92 workspace,
93 session_id: session_id.into(),
94 trigger,
95 hooks: compiled,
96 workspace_gated,
97 command_digest,
98 state: Mutex::new(LifecycleHookState {
99 transcript_path: None,
100 approved_workspace_digest: None,
101 }),
102 }),
103 }))
104 }
105
106 pub async fn run_session_start(&self) -> Result<SessionStartHookOutcome> {
107 let mut messages = Vec::new();
108 let mut additional_context = Vec::new();
109
110 if self.inner.hooks.session_start.is_empty() {
111 return Ok(SessionStartHookOutcome { messages, additional_context });
112 }
113
114 let trigger_value = self.inner.trigger.as_str().to_owned();
115 let payload = self.build_session_start_payload().await?;
116
117 for group in &self.inner.hooks.session_start {
118 if !group.matcher.matches(&trigger_value) {
119 continue;
120 }
121
122 for command in &group.commands {
123 match self.execute_command("SessionStart", command, &payload).await {
124 Ok(result) => interpret_session_start(
125 command,
126 &result,
127 &mut messages,
128 &mut additional_context,
129 self.inner.hooks.quiet_success_output,
130 ),
131 Err(err) => messages
132 .push(HookMessage::error(format!("SessionStart hook `{}` failed: {err}", command.command))),
133 }
134 }
135 }
136
137 Ok(SessionStartHookOutcome { messages, additional_context })
138 }
139
140 pub async fn run_session_end(&self, turn_id: &str, reason: SessionEndReason) -> Result<Vec<HookMessage>> {
141 let mut messages = Vec::new();
142
143 if self.inner.hooks.session_end.is_empty() {
144 return Ok(messages);
145 }
146
147 let payload = self.build_session_end_payload(turn_id, reason).await?;
148 let reason_value = reason.as_str().to_owned();
149
150 for group in &self.inner.hooks.session_end {
151 if !group.matcher.matches(&reason_value) {
152 continue;
153 }
154
155 for command in &group.commands {
156 match self.execute_command("SessionEnd", command, &payload).await {
157 Ok(result) => {
158 interpret_session_end(command, &result, &mut messages, self.inner.hooks.quiet_success_output)
159 }
160 Err(err) => messages
161 .push(HookMessage::error(format!("SessionEnd hook `{}` failed: {err}", command.command))),
162 }
163 }
164 }
165
166 Ok(messages)
167 }
168
169 pub async fn run_subagent_start(
170 &self,
171 parent_session_id: &str,
172 child_thread_id: &str,
173 agent_name: &str,
174 display_label: &str,
175 background: bool,
176 status: &str,
177 transcript_path: Option<&std::path::Path>,
178 ) -> Result<Vec<HookMessage>> {
179 let mut messages = Vec::new();
180
181 if self.inner.hooks.subagent_start.is_empty() {
182 return Ok(messages);
183 }
184
185 let payload = self
186 .build_subagent_start_payload(
187 parent_session_id,
188 child_thread_id,
189 agent_name,
190 display_label,
191 background,
192 status,
193 transcript_path,
194 )
195 .await?;
196 let matcher_value = agent_name.to_owned();
197
198 for group in &self.inner.hooks.subagent_start {
199 if !group.matcher.matches(&matcher_value) {
200 continue;
201 }
202
203 for command in &group.commands {
204 match self.execute_command("SubagentStart", command, &payload).await {
205 Ok(result) => {
206 interpret_session_end(command, &result, &mut messages, self.inner.hooks.quiet_success_output)
207 }
208 Err(err) => messages
209 .push(HookMessage::error(format!("SubagentStart hook `{}` failed: {err}", command.command))),
210 }
211 }
212 }
213
214 Ok(messages)
215 }
216
217 pub async fn run_subagent_stop(
218 &self,
219 parent_session_id: &str,
220 child_thread_id: &str,
221 agent_name: &str,
222 display_label: &str,
223 background: bool,
224 status: &str,
225 transcript_path: Option<&std::path::Path>,
226 ) -> Result<Vec<HookMessage>> {
227 let mut messages = Vec::new();
228
229 if self.inner.hooks.subagent_stop.is_empty() {
230 return Ok(messages);
231 }
232
233 let payload = self
234 .build_subagent_stop_payload(
235 parent_session_id,
236 child_thread_id,
237 agent_name,
238 display_label,
239 background,
240 status,
241 transcript_path,
242 )
243 .await?;
244 let matcher_value = agent_name.to_owned();
245
246 for group in &self.inner.hooks.subagent_stop {
247 if !group.matcher.matches(&matcher_value) {
248 continue;
249 }
250
251 for command in &group.commands {
252 match self.execute_command("SubagentStop", command, &payload).await {
253 Ok(result) => {
254 interpret_session_end(command, &result, &mut messages, self.inner.hooks.quiet_success_output)
255 }
256 Err(err) => messages
257 .push(HookMessage::error(format!("SubagentStop hook `{}` failed: {err}", command.command))),
258 }
259 }
260 }
261
262 Ok(messages)
263 }
264
265 pub async fn run_user_prompt_submit(&self, turn_id: &str, prompt: &str) -> Result<UserPromptHookOutcome> {
267 let mut outcome = UserPromptHookOutcome::default();
268
269 if self.inner.hooks.user_prompt_submit.is_empty() {
270 return Ok(outcome);
271 }
272
273 let payload = self.build_user_prompt_payload(turn_id, prompt).await?;
274
275 for group in &self.inner.hooks.user_prompt_submit {
276 if !group.matcher.matches(prompt) {
277 continue;
278 }
279
280 for command in &group.commands {
281 match self.execute_command("UserPromptSubmit", command, &payload).await {
282 Ok(result) => {
283 interpret_user_prompt(command, &result, &mut outcome, self.inner.hooks.quiet_success_output);
284 if !outcome.allow_prompt {
285 return Ok(outcome);
286 }
287 }
288 Err(err) => outcome
289 .messages
290 .push(HookMessage::error(format!("UserPromptSubmit hook `{}` failed: {err}", command.command))),
291 }
292 }
293 }
294
295 Ok(outcome)
296 }
297
298 pub async fn run_permission_request(
300 &self,
301 tool_name: &str,
302 tool_input: Option<&Value>,
303 permission_request: &PermissionRequest,
304 permission_suggestions: &[Value],
305 ) -> Result<PermissionRequestHookOutcome> {
306 let mut outcome = PermissionRequestHookOutcome::default();
307
308 if self.inner.hooks.permission_request.is_empty() {
309 return Ok(outcome);
310 }
311
312 let payload = self
313 .build_permission_request_payload(tool_name, tool_input, permission_request, permission_suggestions)
314 .await?;
315
316 for group in &self.inner.hooks.permission_request {
317 if !group.matcher.matches(tool_name) {
318 continue;
319 }
320
321 for command in &group.commands {
322 match self.execute_command("PermissionRequest", command, &payload).await {
323 Ok(result) => {
324 interpret_permission_request(
325 command,
326 &result,
327 &mut outcome,
328 self.inner.hooks.quiet_success_output,
329 );
330 if outcome.decision.is_some() {
331 return Ok(outcome);
332 }
333 }
334 Err(err) => outcome.messages.push(HookMessage::error(format!(
335 "PermissionRequest hook `{}` failed: {err}",
336 command.command
337 ))),
338 }
339 }
340 }
341
342 Ok(outcome)
343 }
344
345 pub async fn run_pre_tool_use(
353 &self,
354 tool_name: &str,
355 tool_input: Option<&Value>,
356 tool_call_id: Option<&str>,
357 ) -> Result<PreToolHookOutcome> {
358 let mut outcome = PreToolHookOutcome::default();
359
360 if self.inner.hooks.pre_tool_use.is_empty() {
361 return Ok(outcome);
362 }
363
364 let mut payload = self.build_pre_tool_payload(tool_name, tool_input, tool_call_id).await?;
365
366 for group in &self.inner.hooks.pre_tool_use {
367 if !group.matcher.matches(tool_name) {
368 continue;
369 }
370
371 for command in &group.commands {
372 match self.execute_command("PreToolUse", command, &payload).await {
373 Ok(result) => {
374 interpret_pre_tool(command, &result, &mut outcome, self.inner.hooks.quiet_success_output);
375 if let Some(updated) = outcome.updated_input.take() {
376 outcome.updated_input = Some(updated.clone());
377 if let Some(payload_obj) = payload.as_object_mut() {
378 payload_obj.insert("tool_input".to_string(), updated);
379 }
380 }
381 match outcome.decision {
382 PreToolHookDecision::Allow | PreToolHookDecision::Deny => {
383 return Ok(outcome);
384 }
385 _ => {}
386 }
387 }
388 Err(err) => outcome
389 .messages
390 .push(HookMessage::error(format!("PreToolUse hook `{}` failed: {err}", command.command))),
391 }
392 }
393 }
394
395 Ok(outcome)
396 }
397
398 pub async fn run_post_tool_use(
400 &self,
401 tool_name: &str,
402 tool_input: Option<&Value>,
403 tool_output: &Value,
404 tool_call_id: Option<&str>,
405 ) -> Result<PostToolHookOutcome> {
406 let mut outcome = PostToolHookOutcome::default();
407
408 if self.inner.hooks.post_tool_use.is_empty() {
409 return Ok(outcome);
410 }
411
412 let payload = self
413 .build_post_tool_payload(tool_name, tool_input, tool_output, tool_call_id)
414 .await?;
415
416 for group in &self.inner.hooks.post_tool_use {
417 if !group.matcher.matches(tool_name) {
418 continue;
419 }
420
421 for command in &group.commands {
422 match self.execute_command("PostToolUse", command, &payload).await {
423 Ok(result) => {
424 interpret_post_tool(command, &result, &mut outcome, self.inner.hooks.quiet_success_output)
425 }
426 Err(err) => outcome
427 .messages
428 .push(HookMessage::error(format!("PostToolUse hook `{}` failed: {err}", command.command))),
429 }
430 }
431 }
432
433 Ok(outcome)
434 }
435
436 pub async fn run_pre_compact(
438 &self,
439 trigger: CompactionTrigger,
440 mode: CompactionMode,
441 original_message_count: usize,
442 compacted_message_count: usize,
443 history_artifact_path: Option<&str>,
444 ) -> Result<PreCompactHookOutcome> {
445 let mut outcome = PreCompactHookOutcome::default();
446
447 if self.inner.hooks.pre_compact.is_empty() {
448 return Ok(outcome);
449 }
450
451 let payload = self
452 .build_pre_compact_payload(
453 trigger,
454 mode,
455 original_message_count,
456 compacted_message_count,
457 history_artifact_path,
458 )
459 .await?;
460 let trigger_value = trigger.as_str().to_owned();
461
462 for group in &self.inner.hooks.pre_compact {
463 if !group.matcher.matches(&trigger_value) {
464 continue;
465 }
466
467 for command in &group.commands {
468 match self.execute_command("PreCompact", command, &payload).await {
469 Ok(result) => {
470 interpret_session_end(
471 command,
472 &result,
473 &mut outcome.messages,
474 self.inner.hooks.quiet_success_output,
475 );
476 }
477 Err(err) => outcome
478 .messages
479 .push(HookMessage::error(format!("PreCompact hook `{}` failed: {err}", command.command))),
480 }
481 }
482 }
483
484 Ok(outcome)
485 }
486
487 pub async fn run_notification(
489 &self,
490 notification_type: NotificationHookType,
491 title: &str,
492 message: &str,
493 ) -> Result<Vec<HookMessage>> {
494 let mut messages = Vec::new();
495
496 if self.inner.hooks.notification.is_empty() {
497 return Ok(messages);
498 }
499
500 let payload = self.build_notification_payload(notification_type, title, message).await?;
501 let matcher_value = notification_type.as_str().to_owned();
502
503 for group in &self.inner.hooks.notification {
504 if !group.matcher.matches(&matcher_value) {
505 continue;
506 }
507
508 for command in &group.commands {
509 match self.execute_command("Notification", command, &payload).await {
510 Ok(result) => {
511 interpret_session_end(command, &result, &mut messages, self.inner.hooks.quiet_success_output)
512 }
513 Err(err) => messages
514 .push(HookMessage::error(format!("Notification hook `{}` failed: {err}", command.command))),
515 }
516 }
517 }
518
519 Ok(messages)
520 }
521
522 pub async fn run_stop(&self, last_assistant_message: &str, stop_hook_active: bool) -> Result<StopHookOutcome> {
524 let mut outcome = StopHookOutcome::default();
525
526 if self.inner.hooks.stop.is_empty() {
527 return Ok(outcome);
528 }
529
530 let payload = self.build_stop_payload(last_assistant_message, stop_hook_active).await?;
531
532 for group in &self.inner.hooks.stop {
533 if !group.matcher.matches("stop") {
534 continue;
535 }
536
537 for command in &group.commands {
538 match self.execute_command("Stop", command, &payload).await {
539 Ok(result) => {
540 interpret_stop(command, &result, &mut outcome, self.inner.hooks.quiet_success_output);
541 if outcome.block_reason.is_some() {
542 return Ok(outcome);
543 }
544 }
545 Err(err) => outcome
546 .messages
547 .push(HookMessage::error(format!("Stop hook `{}` failed: {err}", command.command))),
548 }
549 }
550 }
551
552 Ok(outcome)
553 }
554
555 pub async fn update_transcript_path(&self, path: Option<PathBuf>) {
557 let mut state = self.inner.state.lock().await;
558 state.transcript_path = path;
559 }
560
561 pub async fn transcript_path(&self) -> Option<PathBuf> {
563 let state = self.inner.state.lock().await;
564 state.transcript_path.clone()
565 }
566
567 pub fn workspace_gated(&self) -> bool {
569 self.inner.workspace_gated
570 }
571
572 pub fn command_digest(&self) -> &str {
575 &self.inner.command_digest
576 }
577
578 pub fn command_previews(&self) -> Vec<LifecycleHookCommandPreview> {
581 let mut previews = Vec::new();
582 self.inner.hooks.for_each_command(|event, matcher, command| {
583 previews.push(LifecycleHookCommandPreview {
584 event,
585 matcher: matcher.map(str::to_string),
586 command: command.command.clone(),
587 timeout_seconds: command.timeout_seconds,
588 });
589 });
590 previews
591 }
592
593 pub async fn approve_workspace_hooks(&self) {
596 let mut state = self.inner.state.lock().await;
597 state.approved_workspace_digest = Some(self.inner.command_digest.clone());
598 }
599
600 pub async fn workspace_hooks_need_approval(&self) -> bool {
604 if !self.inner.workspace_gated {
605 return false;
606 }
607 let state = self.inner.state.lock().await;
608 state.approved_workspace_digest.as_deref() != Some(self.inner.command_digest.as_str())
609 }
610
611 async fn workspace_approval_granted(&self) -> bool {
615 if !self.inner.workspace_gated {
616 return true;
617 }
618 let state = self.inner.state.lock().await;
619 state.approved_workspace_digest.as_deref() == Some(self.inner.command_digest.as_str())
620 }
621
622 async fn execute_command(
623 &self,
624 event_name: &str,
625 command: &HookCommandConfig,
626 payload: &Value,
627 ) -> Result<HookCommandResult> {
628 if !self.workspace_approval_granted().await {
629 return Err(anyhow::anyhow!(
630 "skipped: the workspace configuration defines lifecycle hooks that are not approved for this workspace. \
631 Approve workspace lifecycle hooks at session start to enable `{}` (event: {event_name}).",
632 command.command
633 ));
634 }
635
636 let mut process = Command::new("sh");
637 process.arg("-c").arg(&command.command);
638 process.current_dir(&self.inner.workspace);
639 process.stdin(Stdio::piped());
640 process.stdout(Stdio::piped());
641 process.stderr(Stdio::piped());
642 process.kill_on_drop(true);
643
644 let workspace_str = self.inner.workspace.to_string_lossy().into_owned();
645 process.env("VT_PROJECT_DIR", &workspace_str);
646 process.env("CLAUDE_PROJECT_DIR", &workspace_str);
647 process.env("VT_SESSION_ID", &self.inner.session_id);
648 process.env("CLAUDE_SESSION_ID", &self.inner.session_id);
649 process.env("VT_HOOK_EVENT", event_name);
650
651 if let Some(transcript_path) = self.current_transcript_path().await {
652 process.env("VT_TRANSCRIPT_PATH", &transcript_path);
653 process.env("CLAUDE_TRANSCRIPT_PATH", &transcript_path);
654 }
655
656 let mut child = process
657 .spawn()
658 .with_context(|| format!("failed to spawn lifecycle hook `{}`", command.command))?;
659
660 if let Some(mut stdin) = child.stdin.take() {
661 let mut payload_bytes =
662 serde_json::to_vec(payload).context("failed to serialize lifecycle hook payload")?;
663 payload_bytes.push(b'\n');
664 stdin
665 .write_all(&payload_bytes)
666 .await
667 .context("failed to write lifecycle hook payload")?;
668 stdin.shutdown().await.context("failed to close lifecycle hook stdin")?;
669 }
670
671 let mut stdout_pipe = child.stdout.take().context("lifecycle hook missing stdout pipe")?;
672 let mut stderr_pipe = child.stderr.take().context("lifecycle hook missing stderr pipe")?;
673
674 let stdout_task = tokio::spawn(async move {
675 let mut buffer = Vec::new();
676 stdout_pipe.read_to_end(&mut buffer).await.map(|_| buffer)
677 });
678 let stderr_task = tokio::spawn(async move {
679 let mut buffer = Vec::new();
680 stderr_pipe.read_to_end(&mut buffer).await.map(|_| buffer)
681 });
682
683 let timeout_secs = command.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECS).max(1);
684 let wait_result = time::timeout(Duration::from_secs(timeout_secs), child.wait()).await;
685
686 let (exit_code, timed_out) = match wait_result {
687 Ok(status_res) => {
688 let status = status_res.context("failed to wait for lifecycle hook")?;
689 (status.code(), false)
690 }
691 Err(_) => {
692 let _ = child.start_kill();
693 let _ = child.wait().await;
694 (None, true)
695 }
696 };
697
698 let stdout_bytes = stdout_task.await.unwrap_or_else(|_| Ok(Vec::new())).unwrap_or_default();
699 let stderr_bytes = stderr_task.await.unwrap_or_else(|_| Ok(Vec::new())).unwrap_or_default();
700
701 Ok(HookCommandResult {
702 exit_code,
703 stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
704 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
705 timed_out,
706 timeout_seconds: timeout_secs,
707 })
708 }
709
710 async fn current_transcript_path(&self) -> Option<String> {
711 let state = self.inner.state.lock().await;
712 state.transcript_path.as_ref().and_then(|path| path_to_string(path))
713 }
714}
715
716struct LifecycleHookInner {
717 workspace: PathBuf,
718 session_id: String,
719 trigger: SessionStartTrigger,
720 hooks: CompiledLifecycleHooks,
721 workspace_gated: bool,
722 command_digest: String,
723 state: Mutex<LifecycleHookState>,
724}
725
726struct LifecycleHookState {
727 transcript_path: Option<PathBuf>,
728 approved_workspace_digest: Option<String>,
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
735pub struct LifecycleHookCommandPreview {
736 pub event: &'static str,
738 pub matcher: Option<String>,
740 pub command: String,
742 pub timeout_seconds: Option<u64>,
744}
745
746fn commands_digest(hooks: &CompiledLifecycleHooks) -> String {
753 let mut lines: Vec<String> = Vec::new();
754 hooks.for_each_command(|event, matcher, command| {
755 let entry = LifecycleHookCommandPreview {
756 event,
757 matcher: matcher.map(str::to_string),
758 command: command.command.clone(),
759 timeout_seconds: command.timeout_seconds,
760 };
761 if let Ok(line) = serde_json::to_string(&entry) {
762 lines.push(line);
763 }
764 });
765 lines.sort();
766 vtcode_commons::utils::calculate_sha256(lines.join("\n").as_bytes())
767}
768
769pub async fn restore_workspace_hook_approval(engine: &LifecycleHookEngine, workspace: &std::path::Path) {
774 if !engine.workspace_gated() {
775 return;
776 }
777 let digest = engine.command_digest().to_string();
778 let Ok(Some(record)) = load_lifecycle_hook_approval(workspace).await else {
779 return;
780 };
781 if record.config_digest == digest {
782 engine.approve_workspace_hooks().await;
783 }
784}
785
786pub async fn carry_or_restore_workspace_hook_approval(
794 previous: Option<&LifecycleHookEngine>,
795 next: &LifecycleHookEngine,
796 workspace: &std::path::Path,
797) {
798 let previous_approved_same_set = match previous {
799 Some(prev) => {
800 prev.workspace_gated()
801 && prev.command_digest() == next.command_digest()
802 && !prev.workspace_hooks_need_approval().await
803 }
804 None => false,
805 };
806 if previous_approved_same_set {
807 next.approve_workspace_hooks().await;
808 } else {
809 restore_workspace_hook_approval(next, workspace).await;
810 }
811}