Skip to main content

magi_code/config/
hooks.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4const HOOK_DEFAULT_TIMEOUT_SECONDS: u64 = 5;
5
6const HOOK_MAX_TIMEOUT_SECONDS: u64 = 60;
7const HOOK_DEFAULT_OUTPUT_MAX_BYTES: usize = 8192;
8const HOOK_MAX_OUTPUT_MAX_BYTES: usize = 65536;
9pub(crate) const HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES: usize = 4096;
10pub(crate) const HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES: usize = 16384;
11
12#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
13pub struct HookSettings {
14    #[serde(default)]
15    pub enabled: bool,
16    #[serde(default)]
17    pub show_in_tui: bool,
18    #[serde(default)]
19    pub payload: HookPayloadMode,
20    #[serde(default = "default_hook_timeout_seconds")]
21    pub timeout_seconds: u64,
22    #[serde(default = "default_hook_output_max_bytes")]
23    pub stdout_max_bytes: usize,
24    #[serde(default = "default_hook_output_max_bytes")]
25    pub stderr_max_bytes: usize,
26    #[serde(default)]
27    pub failure_policy: HookFailurePolicy,
28    #[serde(default)]
29    pub provider_context_injection: bool,
30    #[serde(default = "default_hook_provider_context_max_bytes")]
31    pub provider_context_max_bytes: usize,
32    #[serde(default)]
33    pub injected_content: InjectedContentSettings,
34    #[serde(default, deserialize_with = "deserialize_hook_definitions")]
35    pub before_tool: Vec<HookDefinition>,
36    #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
37    pub after_tool: Vec<HookDefinition>,
38    #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
39    pub after_assistant: Vec<HookDefinition>,
40    #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
41    pub after_reasoning: Vec<HookDefinition>,
42}
43
44impl Default for HookSettings {
45    fn default() -> Self {
46        Self {
47            enabled: false,
48            show_in_tui: false,
49            payload: HookPayloadMode::Redacted,
50            timeout_seconds: HOOK_DEFAULT_TIMEOUT_SECONDS,
51            stdout_max_bytes: HOOK_DEFAULT_OUTPUT_MAX_BYTES,
52            stderr_max_bytes: HOOK_DEFAULT_OUTPUT_MAX_BYTES,
53            failure_policy: HookFailurePolicy::Warn,
54            provider_context_injection: false,
55            provider_context_max_bytes: HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES,
56            injected_content: InjectedContentSettings::default(),
57            before_tool: Vec::new(),
58            after_tool: Vec::new(),
59            after_assistant: Vec::new(),
60            after_reasoning: Vec::new(),
61        }
62    }
63}
64
65impl HookSettings {
66    pub fn is_default(&self) -> bool {
67        self == &Self::default()
68    }
69
70    fn validate(&self) -> Result<(), String> {
71        validate_hook_timeout(self.timeout_seconds)?;
72        validate_hook_output_limit(self.stdout_max_bytes, "stdout_max_bytes")?;
73        validate_hook_output_limit(self.stderr_max_bytes, "stderr_max_bytes")?;
74        validate_hook_provider_context_limit(
75            self.provider_context_max_bytes,
76            "provider_context_max_bytes",
77        )?;
78        for hook in &self.before_tool {
79            hook.validate(false)?;
80        }
81        validate_post_phase_hooks(&self.after_tool, self.failure_policy)?;
82        validate_post_phase_hooks(&self.after_assistant, self.failure_policy)?;
83        validate_post_phase_hooks(&self.after_reasoning, self.failure_policy)?;
84        Ok(())
85    }
86}
87
88#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum HookPayloadMode {
91    #[default]
92    Redacted,
93    Full,
94}
95
96impl HookPayloadMode {
97    pub(crate) fn as_str(self) -> &'static str {
98        match self {
99            Self::Redacted => "redacted",
100            Self::Full => "full",
101        }
102    }
103}
104
105#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum HookFailurePolicy {
108    Ignore,
109    #[default]
110    Warn,
111    Block,
112    Fail,
113}
114
115impl HookFailurePolicy {
116    pub(crate) fn as_str(self) -> &'static str {
117        match self {
118            Self::Ignore => "ignore",
119            Self::Warn => "warn",
120            Self::Block => "block",
121            Self::Fail => "fail",
122        }
123    }
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
127pub struct InjectedContentSettings {
128    #[serde(default)]
129    pub show_in_transcript: bool,
130    #[serde(default)]
131    pub show_in_activity_tree: bool,
132    #[serde(default)]
133    pub style: InjectedContentStyle,
134}
135
136#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
137#[serde(rename_all = "snake_case")]
138pub enum InjectedContentStyle {
139    #[default]
140    Content,
141    Metadata,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
145pub struct HookDefinition {
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub label: Option<String>,
148    pub command: String,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub payload: Option<HookPayloadMode>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub failure_policy: Option<HookFailurePolicy>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub timeout_seconds: Option<u64>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub stdout_max_bytes: Option<usize>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub stderr_max_bytes: Option<usize>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub provider_context_injection: Option<bool>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub provider_context_max_bytes: Option<usize>,
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub include_tools: Vec<String>,
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub exclude_tools: Vec<String>,
167}
168
169impl HookDefinition {
170    pub(crate) fn effective_label(&self) -> String {
171        self.label
172            .as_deref()
173            .map(str::trim)
174            .filter(|label| !label.is_empty())
175            .unwrap_or("hook")
176            .to_string()
177    }
178
179    pub(crate) fn matches_tool(&self, tool_name: &str) -> bool {
180        (self.include_tools.is_empty() || self.include_tools.iter().any(|tool| tool == tool_name))
181            && !self.exclude_tools.iter().any(|tool| tool == tool_name)
182    }
183
184    pub(crate) fn effective_provider_context_injection(&self, settings: &HookSettings) -> bool {
185        self.provider_context_injection
186            .unwrap_or(settings.provider_context_injection)
187    }
188
189    pub(crate) fn effective_provider_context_max_bytes(&self, settings: &HookSettings) -> usize {
190        self.provider_context_max_bytes
191            .unwrap_or(settings.provider_context_max_bytes)
192    }
193
194    fn validate(&self, after: bool) -> Result<(), String> {
195        if self.command.trim().is_empty() {
196            return Err("hook command must be non-empty".to_string());
197        }
198        if after && self.failure_policy == Some(HookFailurePolicy::Block) {
199            return Err(
200                "hook failure_policy 'block' is valid only for before_tool hooks".to_string(),
201            );
202        }
203        if let Some(timeout) = self.timeout_seconds {
204            validate_hook_timeout(timeout)?;
205        }
206        if let Some(limit) = self.stdout_max_bytes {
207            validate_hook_output_limit(limit, "stdout_max_bytes")?;
208        }
209        if let Some(limit) = self.stderr_max_bytes {
210            validate_hook_output_limit(limit, "stderr_max_bytes")?;
211        }
212        if let Some(limit) = self.provider_context_max_bytes {
213            validate_hook_provider_context_limit(limit, "provider_context_max_bytes")?;
214        }
215        Ok(())
216    }
217}
218
219fn default_hook_timeout_seconds() -> u64 {
220    HOOK_DEFAULT_TIMEOUT_SECONDS
221}
222
223fn default_hook_output_max_bytes() -> usize {
224    HOOK_DEFAULT_OUTPUT_MAX_BYTES
225}
226
227fn default_hook_provider_context_max_bytes() -> usize {
228    HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES
229}
230
231fn validate_hook_timeout(value: u64) -> Result<(), String> {
232    if (1..=HOOK_MAX_TIMEOUT_SECONDS).contains(&value) {
233        Ok(())
234    } else {
235        Err(format!(
236            "hooks timeout_seconds must be between 1 and {HOOK_MAX_TIMEOUT_SECONDS}"
237        ))
238    }
239}
240
241fn validate_hook_output_limit(value: usize, field: &str) -> Result<(), String> {
242    if (1..=HOOK_MAX_OUTPUT_MAX_BYTES).contains(&value) {
243        Ok(())
244    } else {
245        Err(format!(
246            "hooks {field} must be between 1 and {HOOK_MAX_OUTPUT_MAX_BYTES}"
247        ))
248    }
249}
250
251fn validate_hook_provider_context_limit(value: usize, field: &str) -> Result<(), String> {
252    if (1..=HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES).contains(&value) {
253        Ok(())
254    } else {
255        Err(format!(
256            "hooks {field} must be between 1 and {HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES}"
257        ))
258    }
259}
260
261fn validate_post_phase_hooks(
262    hooks: &[HookDefinition],
263    default_policy: HookFailurePolicy,
264) -> Result<(), String> {
265    for hook in hooks {
266        hook.validate(true)?;
267        if hook.failure_policy.unwrap_or(default_policy) == HookFailurePolicy::Block {
268            return Err(
269                "hook failure_policy 'block' is valid only for before_tool hooks".to_string(),
270            );
271        }
272    }
273    Ok(())
274}
275
276fn deserialize_hook_definitions<'de, D>(deserializer: D) -> Result<Vec<HookDefinition>, D::Error>
277where
278    D: Deserializer<'de>,
279{
280    let hooks = Vec::<HookDefinition>::deserialize(deserializer)?;
281    for hook in &hooks {
282        hook.validate(false).map_err(serde::de::Error::custom)?;
283    }
284    Ok(hooks)
285}
286
287fn deserialize_after_hook_definitions<'de, D>(
288    deserializer: D,
289) -> Result<Vec<HookDefinition>, D::Error>
290where
291    D: Deserializer<'de>,
292{
293    let hooks = Vec::<HookDefinition>::deserialize(deserializer)?;
294    for hook in &hooks {
295        hook.validate(true).map_err(serde::de::Error::custom)?;
296    }
297    Ok(hooks)
298}
299
300impl<'de> Deserialize<'de> for HookSettings {
301    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
302    where
303        D: Deserializer<'de>,
304    {
305        #[derive(Deserialize)]
306        struct RawHookSettings {
307            #[serde(default)]
308            enabled: bool,
309            #[serde(default)]
310            show_in_tui: bool,
311            #[serde(default)]
312            payload: HookPayloadMode,
313            #[serde(default = "default_hook_timeout_seconds")]
314            timeout_seconds: u64,
315            #[serde(default = "default_hook_output_max_bytes")]
316            stdout_max_bytes: usize,
317            #[serde(default = "default_hook_output_max_bytes")]
318            stderr_max_bytes: usize,
319            #[serde(default)]
320            failure_policy: HookFailurePolicy,
321            #[serde(default)]
322            provider_context_injection: bool,
323            #[serde(default = "default_hook_provider_context_max_bytes")]
324            provider_context_max_bytes: usize,
325            #[serde(default)]
326            injected_content: InjectedContentSettings,
327            #[serde(default, deserialize_with = "deserialize_hook_definitions")]
328            before_tool: Vec<HookDefinition>,
329            #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
330            after_tool: Vec<HookDefinition>,
331            #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
332            after_assistant: Vec<HookDefinition>,
333            #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
334            after_reasoning: Vec<HookDefinition>,
335        }
336        let raw = RawHookSettings::deserialize(deserializer)?;
337        let settings = HookSettings {
338            enabled: raw.enabled,
339            show_in_tui: raw.show_in_tui,
340            payload: raw.payload,
341            timeout_seconds: raw.timeout_seconds,
342            stdout_max_bytes: raw.stdout_max_bytes,
343            stderr_max_bytes: raw.stderr_max_bytes,
344            failure_policy: raw.failure_policy,
345            provider_context_injection: raw.provider_context_injection,
346            provider_context_max_bytes: raw.provider_context_max_bytes,
347            injected_content: raw.injected_content,
348            before_tool: raw.before_tool,
349            after_tool: raw.after_tool,
350            after_assistant: raw.after_assistant,
351            after_reasoning: raw.after_reasoning,
352        };
353        settings.validate().map_err(serde::de::Error::custom)?;
354        Ok(settings)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::config::Settings;
362    use schemars::schema_for;
363    use serde_json::json;
364
365    #[test]
366    fn injected_content_defaults_when_missing() {
367        let settings: HookSettings = serde_json::from_str("{}").unwrap();
368
369        assert_eq!(
370            settings.injected_content,
371            InjectedContentSettings::default()
372        );
373        assert!(!settings.injected_content.show_in_transcript);
374        assert!(!settings.injected_content.show_in_activity_tree);
375        assert_eq!(
376            settings.injected_content.style,
377            InjectedContentStyle::Content
378        );
379    }
380
381    #[test]
382    fn injected_content_deserializes_explicit_values() {
383        let settings: HookSettings = serde_json::from_value(json!({
384            "injected_content": {
385                "show_in_transcript": true,
386                "show_in_activity_tree": true,
387                "style": "metadata"
388            }
389        }))
390        .unwrap();
391
392        assert!(settings.injected_content.show_in_transcript);
393        assert!(settings.injected_content.show_in_activity_tree);
394        assert_eq!(
395            settings.injected_content.style,
396            InjectedContentStyle::Metadata
397        );
398    }
399
400    #[test]
401    fn injected_content_serializes_non_defaults() {
402        let settings = HookSettings {
403            injected_content: InjectedContentSettings {
404                show_in_transcript: true,
405                show_in_activity_tree: true,
406                style: InjectedContentStyle::Metadata,
407            },
408            ..HookSettings::default()
409        };
410
411        let value = serde_json::to_value(settings).unwrap();
412        assert_eq!(value["injected_content"]["show_in_transcript"], true);
413        assert_eq!(value["injected_content"]["show_in_activity_tree"], true);
414        assert_eq!(value["injected_content"]["style"], "metadata");
415    }
416
417    #[test]
418    fn injected_content_rejects_unknown_style() {
419        let error = serde_json::from_value::<HookSettings>(json!({
420            "injected_content": { "style": "raw" }
421        }))
422        .unwrap_err()
423        .to_string();
424
425        assert!(error.contains("unknown variant"), "{error}");
426    }
427
428    #[test]
429    fn settings_schema_includes_injected_content_fields() {
430        let schema = serde_json::to_value(schema_for!(Settings)).unwrap();
431        let schema_text = serde_json::to_string(&schema).unwrap();
432
433        assert!(schema_text.contains("injected_content"));
434        assert!(schema_text.contains("show_in_transcript"));
435        assert!(schema_text.contains("show_in_activity_tree"));
436        assert!(schema_text.contains("content"));
437        assert!(schema_text.contains("metadata"));
438    }
439}