Skip to main content

vtcode_config/
hooks.rs

1use anyhow::{Context, Result, ensure};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5/// All lifecycle hook event names in canonical (snake_case) form, including
6/// the deprecated aliases that `LifecycleHooksConfig::normalized()` folds into
7/// `stop`. Used to classify which events a workspace-controlled config layer
8/// defines.
9pub(crate) const LIFECYCLE_HOOK_EVENTS: &[&str] = &[
10    "session_start",
11    "session_end",
12    "subagent_start",
13    "subagent_stop",
14    "user_prompt_submit",
15    "pre_tool_use",
16    "post_tool_use",
17    "permission_request",
18    "pre_compact",
19    "stop",
20    "task_completion",
21    "task_completed",
22    "notification",
23];
24
25/// Top-level configuration for automation hooks and lifecycle events
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
28pub struct HooksConfig {
29    /// Configuration for lifecycle-based shell command execution
30    #[serde(default)]
31    pub lifecycle: LifecycleHooksConfig,
32}
33
34/// Configuration for hooks triggered during distinct agent lifecycle events.
35/// Each event supports a list of groups with optional matchers.
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
38pub struct LifecycleHooksConfig {
39    /// Suppress plain stdout from successful hooks unless they emit structured fields
40    #[serde(default)]
41    pub quiet_success_output: bool,
42
43    /// Commands to run immediately when an agent session begins
44    #[serde(default)]
45    pub session_start: Vec<HookGroupConfig>,
46
47    /// Commands to run when an agent session ends
48    #[serde(default)]
49    pub session_end: Vec<HookGroupConfig>,
50
51    /// Commands to run when a delegated subagent starts
52    #[serde(default)]
53    pub subagent_start: Vec<HookGroupConfig>,
54
55    /// Commands to run when a delegated subagent stops
56    #[serde(default)]
57    pub subagent_stop: Vec<HookGroupConfig>,
58
59    /// Commands to run when the user submits a prompt (pre-processing)
60    #[serde(default)]
61    pub user_prompt_submit: Vec<HookGroupConfig>,
62
63    /// Commands to run immediately before a tool is executed
64    #[serde(default)]
65    pub pre_tool_use: Vec<HookGroupConfig>,
66
67    /// Commands to run immediately after a tool returns its output
68    #[serde(default)]
69    pub post_tool_use: Vec<HookGroupConfig>,
70
71    /// Commands to run when VT Code is about to request interactive permission approval
72    #[serde(default)]
73    pub permission_request: Vec<HookGroupConfig>,
74
75    /// Commands to run immediately before VT Code compacts conversation history
76    #[serde(default)]
77    pub pre_compact: Vec<HookGroupConfig>,
78
79    /// Commands to run after VT Code produces a final answer but before the turn completes
80    #[serde(default)]
81    pub stop: Vec<HookGroupConfig>,
82
83    /// Deprecated alias for `stop`
84    #[serde(default)]
85    pub task_completion: Vec<HookGroupConfig>,
86
87    /// Deprecated alias for `stop`
88    #[serde(default)]
89    pub task_completed: Vec<HookGroupConfig>,
90
91    /// Commands to run when VT Code emits a runtime notification event
92    #[serde(default)]
93    pub notification: Vec<HookGroupConfig>,
94}
95
96impl LifecycleHooksConfig {
97    pub fn is_empty(&self) -> bool {
98        self.session_start.is_empty()
99            && self.session_end.is_empty()
100            && self.subagent_start.is_empty()
101            && self.subagent_stop.is_empty()
102            && self.user_prompt_submit.is_empty()
103            && self.pre_tool_use.is_empty()
104            && self.post_tool_use.is_empty()
105            && self.permission_request.is_empty()
106            && self.pre_compact.is_empty()
107            && self.stop.is_empty()
108            && self.task_completion.is_empty()
109            && self.task_completed.is_empty()
110            && self.notification.is_empty()
111    }
112
113    pub fn normalized(&self) -> Self {
114        let mut normalized = self.clone();
115        normalized.stop.extend(self.task_completion.clone());
116        normalized.stop.extend(self.task_completed.clone());
117        normalized.task_completion.clear();
118        normalized.task_completed.clear();
119        normalized
120    }
121
122    /// Return the groups stored under a canonical snake_case event name
123    /// (the literal field; deprecated aliases keep their own fields here and
124    /// are folded into `stop` only by `normalized()`).
125    pub(crate) fn groups_for_event(&self, event: &str) -> &[HookGroupConfig] {
126        match event {
127            "session_start" => &self.session_start,
128            "session_end" => &self.session_end,
129            "subagent_start" => &self.subagent_start,
130            "subagent_stop" => &self.subagent_stop,
131            "user_prompt_submit" => &self.user_prompt_submit,
132            "pre_tool_use" => &self.pre_tool_use,
133            "post_tool_use" => &self.post_tool_use,
134            "permission_request" => &self.permission_request,
135            "pre_compact" => &self.pre_compact,
136            "stop" => &self.stop,
137            "task_completion" => &self.task_completion,
138            "task_completed" => &self.task_completed,
139            "notification" => &self.notification,
140            _ => &[],
141        }
142    }
143}
144
145/// A group of hooks sharing a common execution matcher
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
148pub struct HookGroupConfig {
149    /// Optional regex matcher to filter when this group runs.
150    /// Matched against context strings (e.g. tool name, project path).
151    #[serde(default)]
152    pub matcher: Option<String>,
153
154    /// List of hook commands to execute sequentially in this group
155    #[serde(default)]
156    pub hooks: Vec<HookCommandConfig>,
157}
158
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
161#[serde(rename_all = "snake_case")]
162#[derive(Default)]
163pub enum HookCommandKind {
164    #[default]
165    Command,
166    /// Catch-all for unknown hook kinds added by future versions.
167    #[serde(other)]
168    Unknown,
169}
170
171/// Configuration for a single shell command hook
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
174pub struct HookCommandConfig {
175    /// Type of hook command (currently only 'command' is supported)
176    #[serde(default)]
177    #[serde(rename = "type")]
178    pub kind: HookCommandKind,
179
180    /// The shell command string to execute
181    #[serde(default)]
182    pub command: String,
183
184    /// Optional execution timeout in seconds
185    #[serde(default)]
186    pub timeout_seconds: Option<u64>,
187}
188
189impl HooksConfig {
190    pub fn validate(&self) -> Result<()> {
191        self.lifecycle.validate().context("Invalid lifecycle hooks configuration")
192    }
193}
194
195/// A single lifecycle hook command whose effective configuration origin is a
196/// workspace-controlled layer (workspace-root `vtcode.toml`, the workspace
197/// `.vtcode/vtcode.toml` fallback, or a project profile inside the workspace).
198///
199/// Workspace-controlled configuration is attacker-influenceable in an
200/// untrusted repository, so these commands must never execute until the user
201/// explicitly approves the exact command set for this workspace.
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct WorkspaceHookCommand {
205    /// Lifecycle event name in canonical snake_case form (e.g. `session_start`).
206    /// Deprecated aliases (`task_completion`, `task_completed`) are recorded as
207    /// `stop` because `LifecycleHooksConfig::normalized()` folds them there.
208    pub event: String,
209    /// Optional regex matcher of the containing group.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub matcher: Option<String>,
212    /// The shell command string executed via `sh -c`.
213    pub command: String,
214    /// Optional execution timeout in seconds.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub timeout_seconds: Option<u64>,
217}
218
219/// Snapshot of all lifecycle hook commands sourced from workspace-controlled
220/// configuration layers, captured at configuration load time.
221///
222/// The presence of any workspace-controlled hook command marks the session's
223/// lifecycle engine as gated: no lifecycle hook runs until the user explicitly
224/// approves the exact command set the engine will execute, bound to a digest
225/// of that set. Any change to the commands invalidates a previously granted
226/// approval, so a repository update cannot silently swap in a new command
227/// under an old approval.
228#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct WorkspaceLifecycleHooks {
231    /// Workspace-controlled lifecycle hook commands in canonical event order.
232    pub commands: Vec<WorkspaceHookCommand>,
233}
234
235impl WorkspaceLifecycleHooks {
236    pub fn is_empty(&self) -> bool {
237        self.commands.is_empty()
238    }
239}
240
241impl LifecycleHooksConfig {
242    fn validate(&self) -> Result<()> {
243        validate_groups(&self.session_start, "session_start")?;
244        validate_groups(&self.session_end, "session_end")?;
245        validate_groups(&self.subagent_start, "subagent_start")?;
246        validate_groups(&self.subagent_stop, "subagent_stop")?;
247        validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
248        validate_groups(&self.pre_tool_use, "pre_tool_use")?;
249        validate_groups(&self.post_tool_use, "post_tool_use")?;
250        validate_groups(&self.permission_request, "permission_request")?;
251        validate_groups(&self.pre_compact, "pre_compact")?;
252        validate_groups(&self.stop, "stop")?;
253        validate_groups(&self.task_completion, "task_completion")?;
254        validate_groups(&self.task_completed, "task_completed")?;
255        validate_groups(&self.notification, "notification")?;
256        Ok(())
257    }
258}
259
260fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
261    for (index, group) in groups.iter().enumerate() {
262        if let Some(pattern) = group.matcher.as_ref() {
263            validate_matcher(pattern)
264                .with_context(|| format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher"))?;
265        }
266
267        ensure!(!group.hooks.is_empty(), "hooks.{context_name}[{index}] must define at least one hook command");
268
269        for (hook_index, hook) in group.hooks.iter().enumerate() {
270            ensure!(
271                matches!(hook.kind, HookCommandKind::Command),
272                "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
273            );
274
275            ensure!(
276                !hook.command.trim().is_empty(),
277                "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
278            );
279
280            if let Some(timeout) = hook.timeout_seconds {
281                ensure!(
282                    timeout > 0,
283                    "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
284                );
285            }
286        }
287    }
288
289    Ok(())
290}
291
292fn validate_matcher(pattern: &str) -> Result<()> {
293    let trimmed = pattern.trim();
294    if trimmed.is_empty() || trimmed == "*" {
295        return Ok(());
296    }
297
298    let regex_pattern = format!("^(?:{trimmed})$");
299    Regex::new(&regex_pattern).with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
300    Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn sample_group() -> HookGroupConfig {
308        HookGroupConfig {
309            matcher: None,
310            hooks: vec![HookCommandConfig {
311                kind: HookCommandKind::Command,
312                command: "echo ok".to_string(),
313                timeout_seconds: None,
314            }],
315        }
316    }
317
318    #[test]
319    fn permission_request_and_stop_validate() {
320        let config = LifecycleHooksConfig {
321            permission_request: vec![sample_group()],
322            stop: vec![sample_group()],
323            ..Default::default()
324        };
325
326        config.validate().expect("hooks validate");
327    }
328
329    #[test]
330    fn task_aliases_normalize_into_stop() {
331        let config = LifecycleHooksConfig {
332            task_completion: vec![sample_group()],
333            task_completed: vec![sample_group()],
334            ..Default::default()
335        };
336
337        let normalized = config.normalized();
338        assert_eq!(normalized.stop.len(), 2);
339        assert!(normalized.task_completion.is_empty());
340        assert!(normalized.task_completed.is_empty());
341    }
342}