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}