Skip to main content

vtcode_core/hooks/lifecycle/
engine.rs

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    /// Test-only convenience constructor. Production session paths must use
43    /// [`Self::new_with_session_gated`] so workspace-controlled hook content
44    /// stays fail-closed; this un-gated entry point exists only so test
45    /// configurations can construct engines directly.
46    #[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    /// Test-only convenience constructor; see [`Self::new`].
52    #[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    /// Construct the engine with the workspace approval gate.
63    ///
64    /// When `workspace_gated` is true, the workspace configuration (a
65    /// workspace-root `vtcode.toml`, the workspace `.vtcode/` fallback, a
66    /// project profile, or a workspace-sourced agent spec with hooks) contains
67    /// lifecycle hook commands. No lifecycle command then executes until the
68    /// user explicitly approves the exact command set this engine will run,
69    /// bound to its digest. Callers must pass `true` whenever
70    /// `VTCodeConfig.workspace_lifecycle_hooks` is non-empty or the active
71    /// primary agent contributes workspace-controlled hooks.
72    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    /// Execute all `UserPromptSubmit` hooks that match the prompt content.
266    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    /// Execute all `PermissionRequest` hooks that match the tool name.
299    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    /// Execute all `PreToolUse` hooks that match the tool name.
346    ///
347    /// Hooks run in configuration order. When a hook returns
348    /// `hookSpecificOutput.updatedInput`, later hooks receive the rewritten
349    /// tool input in their payload, so policy hooks placed after rewrite
350    /// hooks observe the final command. The first Allow/Deny decision still
351    /// short-circuits the remaining hooks.
352    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    /// Execute all `PostToolUse` hooks that match the tool name.
399    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    /// Execute all `PreCompact` hooks that match the compaction trigger.
437    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    /// Execute all `Notification` hooks that match the notification type.
488    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    /// Execute all `Stop` hooks.
523    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    /// Update the transcript path that hooks can access via the `VT_TRANSCRIPT_PATH` env var.
556    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    /// Return the current transcript path, if set.
562    pub async fn transcript_path(&self) -> Option<PathBuf> {
563        let state = self.inner.state.lock().await;
564        state.transcript_path.clone()
565    }
566
567    /// Whether the workspace approval gate is active for this engine.
568    pub fn workspace_gated(&self) -> bool {
569        self.inner.workspace_gated
570    }
571
572    /// Digest of the exact lifecycle command set this engine will execute.
573    /// Approvals are bound to this digest and revalidated before every spawn.
574    pub fn command_digest(&self) -> &str {
575        &self.inner.command_digest
576    }
577
578    /// Every lifecycle command this engine will execute, with its canonical
579    /// event key — the exact set an approval covers.
580    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    /// Mark this engine's exact command set as approved for the session.
594    /// Only a digest equal to `command_digest()` satisfies the gate.
595    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    /// Whether the gate is active and the current command set is not yet
601    /// approved. The binary prompts the user before the first lifecycle run
602    /// when this is true.
603    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    /// Fail-closed gate: when workspace-controlled hook content is present,
612    /// the engine refuses to spawn any lifecycle command until its exact
613    /// command set has been approved.
614    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/// A lifecycle hook command exactly as the engine will execute it, with its
732/// canonical snake_case event key. Displayed to the user so an approval covers
733/// the precise command set.
734#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
735pub struct LifecycleHookCommandPreview {
736    /// Canonical event key, e.g. `session_start`.
737    pub event: &'static str,
738    /// Optional regex matcher of the containing group.
739    pub matcher: Option<String>,
740    /// The shell command string executed via `sh -c`.
741    pub command: String,
742    /// Optional execution timeout in seconds.
743    pub timeout_seconds: Option<u64>,
744}
745
746/// Stable digest over the engine's exact compiled command set. Deterministic
747/// across processes so approvals can be persisted and revalidated. Commands
748/// are serialized as JSON lines (length-safe, no delimiter ambiguity) and
749/// hashed with SHA-256, matching the codebase's config-fingerprint approach
750/// (collision-resistant; a crafted command set cannot collide with an
751/// approved one).
752fn 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
769/// Restore a persisted workspace lifecycle-hook approval onto a freshly built
770/// engine. The persisted record only applies when its digest still matches the
771/// engine's exact command set; any configuration change since approval leaves
772/// the engine fail-closed.
773pub 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
786/// Carry an in-memory approval from a previous engine onto a rebuilt engine
787/// (primary-agent switch or config reload), falling back to the persisted
788/// record. A session-only approval that could not be persisted (e.g. dot-config
789/// write failure) survives the rebuild while the command set is unchanged,
790/// instead of being silently dropped mid-session. Fails closed in every other
791/// case: a changed command set, a previously unapproved engine, or a gated
792/// engine with no matching persisted record leaves the rebuilt engine gated.
793pub 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}