1use serde::{Deserialize, Serialize};
15
16use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
17use crate::user_hook_types::{HookSource, UserHookSpec};
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23#[serde(default)]
24pub struct UserHooksConfig {
25 pub hooks: Vec<UserHookSpec>,
26 pub disabled_contributions: Vec<String>,
27}
28
29pub const USER_HOOKS_CAPABILITY_ID: &str = "user_hooks";
30pub const MAX_USER_HOOKS_CONFIG_BYTES: usize = 256 * 1024;
31pub const MAX_USER_HOOKS: usize = 64;
32
33pub struct UserHooksCapability;
34
35impl Capability for UserHooksCapability {
36 fn id(&self) -> &str {
37 USER_HOOKS_CAPABILITY_ID
38 }
39
40 fn name(&self) -> &str {
41 "User Hooks"
42 }
43
44 fn description(&self) -> &str {
45 "Run user-authored shell commands at well-defined points in the agent \
46 execution lifecycle. Hooks can mutate inputs or block execution. See \
47 specs/user-hooks.md for the contract."
48 }
49
50 fn status(&self) -> CapabilityStatus {
51 CapabilityStatus::Available
52 }
53
54 fn category(&self) -> Option<&str> {
55 Some("Automation")
56 }
57
58 fn icon(&self) -> Option<&str> {
59 Some("plug")
60 }
61
62 fn risk_level(&self) -> RiskLevel {
63 RiskLevel::High
68 }
69
70 fn config_schema(&self) -> Option<serde_json::Value> {
71 Some(serde_json::json!({
72 "type": "object",
73 "properties": {
74 "hooks": {
75 "type": "array",
76 "title": "Hooks",
77 "description": "User-authored hook entries (see specs/user-hooks.md#userhookspec).",
78 "items": { "$ref": "#/$defs/UserHookSpec" }
79 },
80 "disabled_contributions": {
81 "type": "array",
82 "title": "Disabled contributed hooks",
83 "description": "Stable HookId strings of capability-contributed hooks to mute. Format: \"{capability_id}:{name}\".",
84 "items": {
85 "type": "string",
86 "title": "Hook ID",
87 "description": "Stable HookId of a capability-contributed hook."
88 }
89 }
90 },
91 "additionalProperties": false,
92 "$defs": {
93 "UserHookSpec": {
94 "type": "object",
95 "required": ["event", "executor"],
96 "properties": {
97 "id": {
98 "type": "string",
99 "title": "Hook ID",
100 "description": "Optional stable identifier for this hook."
101 },
102 "event": {
103 "type": "string",
104 "title": "Event",
105 "description": "Lifecycle event that triggers this hook.",
106 "oneOf": [
107 { "const": "session_start", "title": "Session start" },
108 { "const": "user_prompt_submit", "title": "User prompt submit" },
109 { "const": "pre_tool_use", "title": "Before tool use" },
110 { "const": "post_tool_use", "title": "After tool use" },
111 { "const": "turn_end", "title": "Turn end" },
112 { "const": "session_end", "title": "Session end" }
113 ]
114 },
115 "matcher": {
116 "type": "object",
117 "title": "Matcher",
118 "description": "Optional filter that limits which tool calls trigger this hook (tool events only).",
119 "properties": {
120 "tool_name": {
121 "type": "string",
122 "title": "Tool name",
123 "description": "Exact tool name to match."
124 },
125 "tool_name_glob": {
126 "type": "string",
127 "title": "Tool name glob",
128 "description": "Glob pattern matched against the tool name."
129 },
130 "args_jsonpath": {
131 "type": "string",
132 "title": "Arguments JSONPath",
133 "description": "JSONPath selecting the tool argument value to test."
134 },
135 "match_regex": {
136 "type": "string",
137 "title": "Match regex",
138 "description": "Regex the selected value must match."
139 },
140 "deny_regex": {
141 "type": "string",
142 "title": "Deny regex",
143 "description": "Regex the selected value must not match."
144 }
145 },
146 "additionalProperties": false
147 },
148 "executor": {
149 "type": "object",
150 "title": "Executor",
151 "description": "Command executed when the hook fires.",
152 "required": ["type", "command"],
153 "properties": {
154 "type": {
155 "title": "Executor type",
156 "description": "Executor kind; only bash is supported.",
157 "const": "bash"
158 },
159 "command": {
160 "type": "string",
161 "title": "Command",
162 "description": "Shell command run in the session sandbox."
163 },
164 "env": {
165 "type": "object",
166 "title": "Environment variables",
167 "description": "Extra environment variables passed to the command.",
168 "additionalProperties": { "type": "string" }
169 }
170 }
171 },
172 "timeout_ms": {
173 "type": "integer",
174 "title": "Timeout (ms)",
175 "description": "Maximum hook run time in milliseconds (100-30000).",
176 "minimum": 100,
177 "maximum": 30000
178 },
179 "on_error": {
180 "type": "string",
181 "title": "On error",
182 "description": "What to do when the hook command fails.",
183 "oneOf": [
184 { "const": "block", "title": "Block" },
185 { "const": "allow", "title": "Allow" },
186 { "const": "warn", "title": "Warn" }
187 ]
188 },
189 "description": {
190 "type": "string",
191 "title": "Description",
192 "description": "Optional human-readable note about what this hook does."
193 }
194 }
195 }
196 }
197 }))
198 }
199
200 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
201 if config.is_null() {
204 return Ok(());
205 }
206 let config_bytes = serde_json::to_vec(config)
207 .map_err(|e| format!("user_hooks config serialization failed: {e}"))?
208 .len();
209 if config_bytes > MAX_USER_HOOKS_CONFIG_BYTES {
210 return Err(format!(
211 "user_hooks config is {config_bytes} bytes; maximum is {MAX_USER_HOOKS_CONFIG_BYTES}"
212 ));
213 }
214 let parsed: UserHooksConfig = serde_json::from_value(config.clone())
215 .map_err(|e| format!("user_hooks config parse failed: {e}"))?;
216 if parsed.hooks.len() > MAX_USER_HOOKS {
217 return Err(format!(
218 "user_hooks.hooks has {} entries; maximum is {MAX_USER_HOOKS}",
219 parsed.hooks.len()
220 ));
221 }
222
223 for (idx, hook) in parsed.hooks.iter().enumerate() {
224 hook.validate()
225 .map_err(|e| format!("user_hooks.hooks[{idx}]: {e}"))?;
226 }
227 Ok(())
228 }
229
230 fn localizations(&self) -> Vec<CapabilityLocalization> {
231 vec![
232 CapabilityLocalization {
233 locale: "en",
234 name: None,
235 description: None,
236 config_description: Some(
237 "Defines user-authored hook commands that run at agent lifecycle \
238 events and which capability-contributed hooks to mute.",
239 ),
240 config_overlay: None,
241 },
242 CapabilityLocalization {
243 locale: "uk",
244 name: Some("Хуки користувача"),
245 description: Some(
246 "Виконує написані користувачем шелл-команди у визначених точках \
247 життєвого циклу виконання агента. Хуки можуть змінювати вхідні дані \
248 або блокувати виконання.",
249 ),
250 config_description: Some(
251 "Визначає хуки користувача, що запускаються на подіях життєвого циклу агента, та які хуки від можливостей вимкнути.",
252 ),
253 config_overlay: Some(serde_json::json!({
254 "properties": {
255 "hooks": {
256 "title": "Хуки",
257 "description": "Хуки, створені користувачем (див. specs/user-hooks.md#userhookspec).",
258 "items": {
259 "properties": {
260 "id": {
261 "title": "Ідентифікатор хука",
262 "description": "Необов'язковий стабільний ідентифікатор цього хука."
263 },
264 "event": {
265 "title": "Подія",
266 "description": "Подія життєвого циклу, що запускає цей хук.",
267 "enum_labels": {
268 "session_start": "Початок сесії",
269 "user_prompt_submit": "Надсилання запиту користувача",
270 "pre_tool_use": "Перед викликом інструмента",
271 "post_tool_use": "Після виклику інструмента",
272 "turn_end": "Завершення ходу",
273 "session_end": "Завершення сесії"
274 }
275 },
276 "matcher": {
277 "title": "Фільтр",
278 "description": "Необов'язковий фільтр, що обмежує, які виклики інструментів запускають цей хук (лише для подій інструментів).",
279 "properties": {
280 "tool_name": {
281 "title": "Назва інструмента",
282 "description": "Точна назва інструмента для збігу."
283 },
284 "tool_name_glob": {
285 "title": "Glob назви інструмента",
286 "description": "Glob-шаблон, що зіставляється з назвою інструмента."
287 },
288 "args_jsonpath": {
289 "title": "JSONPath аргументів",
290 "description": "JSONPath, що вибирає значення аргументу інструмента для перевірки."
291 },
292 "match_regex": {
293 "title": "Регулярний вираз збігу",
294 "description": "Регулярний вираз, якому має відповідати вибране значення."
295 },
296 "deny_regex": {
297 "title": "Регулярний вираз заборони",
298 "description": "Регулярний вираз, якому вибране значення не повинно відповідати."
299 }
300 }
301 },
302 "executor": {
303 "title": "Виконавець",
304 "description": "Команда, що виконується, коли спрацьовує хук.",
305 "properties": {
306 "type": {
307 "title": "Тип виконавця",
308 "description": "Вид виконавця; підтримується лише bash."
309 },
310 "command": {
311 "title": "Команда",
312 "description": "Шелл-команда, що виконується в пісочниці сесії."
313 },
314 "env": {
315 "title": "Змінні середовища",
316 "description": "Додаткові змінні середовища, що передаються команді."
317 }
318 }
319 },
320 "timeout_ms": {
321 "title": "Тайм-аут (мс)",
322 "description": "Максимальний час виконання хука в мілісекундах (100-30000)."
323 },
324 "on_error": {
325 "title": "У разі помилки",
326 "description": "Що робити, коли команда хука завершується з помилкою.",
327 "enum_labels": {
328 "block": "Блокувати",
329 "allow": "Дозволити",
330 "warn": "Попередити"
331 }
332 },
333 "description": {
334 "title": "Опис",
335 "description": "Необов'язкова зрозуміла людині нотатка про призначення цього хука."
336 }
337 }
338 }
339 },
340 "disabled_contributions": {
341 "title": "Вимкнені хуки можливостей",
342 "description": "Стабільні ідентифікатори HookId хуків, доданих можливостями, які потрібно вимкнути. Формат: \"{capability_id}:{name}\".",
343 "items": {
344 "title": "Ідентифікатор хука",
345 "description": "Стабільний HookId хука, доданого можливістю."
346 }
347 }
348 }
349 })),
350 },
351 ]
352 }
353
354 fn user_hooks_with_config(&self, config: &serde_json::Value) -> Vec<UserHookSpec> {
355 let parsed: UserHooksConfig = if config.is_null() {
356 return vec![];
357 } else {
358 match serde_json::from_value(config.clone()) {
359 Ok(c) => c,
360 Err(_) => return vec![],
361 }
362 };
363 parsed
366 .hooks
367 .into_iter()
368 .map(|mut hook| {
369 hook.source = HookSource::UserConfig;
370 hook
371 })
372 .collect()
373 }
374}
375
376pub fn disabled_contributions(config: &serde_json::Value) -> Vec<String> {
380 if config.is_null() {
381 return vec![];
382 }
383 serde_json::from_value::<UserHooksConfig>(config.clone())
384 .map(|c| c.disabled_contributions)
385 .unwrap_or_default()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::user_hook_types::HookEvent;
392
393 #[test]
396 fn empty_config_validates() {
397 let cap = UserHooksCapability;
398 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
399 assert!(cap.validate_config(&serde_json::json!({})).is_ok());
400 }
401
402 #[test]
403 fn full_config_validates_and_yields_specs() {
404 let cap = UserHooksCapability;
405 let config = serde_json::json!({
406 "hooks": [
407 {
408 "id": "fmt",
409 "event": "post_tool_use",
410 "matcher": { "tool_name": "edit_file" },
411 "executor": { "type": "bash", "command": "cargo fmt" },
412 "timeout_ms": 5000,
413 "on_error": "warn",
414 "description": "format after edits"
415 }
416 ],
417 "disabled_contributions": ["other_pack:noisy_hook"]
418 });
419 cap.validate_config(&config).expect("validate");
420 let specs = cap.user_hooks_with_config(&config);
421 assert_eq!(specs.len(), 1);
422 assert_eq!(specs[0].event, HookEvent::PostToolUse);
423 assert!(matches!(specs[0].source, HookSource::UserConfig));
424 assert_eq!(specs[0].resolve_id(0).as_str(), "user:fmt");
425 let muted = disabled_contributions(&config);
426 assert_eq!(muted, vec!["other_pack:noisy_hook"]);
427 }
428
429 #[test]
430 fn invalid_hook_in_config_is_rejected() {
431 let cap = UserHooksCapability;
432 let config = serde_json::json!({
433 "hooks": [
434 {
435 "event": "pre_tool_use",
436 "executor": { "type": "bash", "command": "true" },
437 "timeout_ms": 50
438 }
439 ]
440 });
441 let err = cap.validate_config(&config).unwrap_err();
442 assert!(err.contains("timeout_ms"), "{}", err);
443 }
444
445 #[test]
446 fn too_many_user_hooks_are_rejected_before_per_hook_validation() {
447 let cap = UserHooksCapability;
448 let hooks = (0..=MAX_USER_HOOKS)
449 .map(|_| {
450 serde_json::json!({
451 "event": "pre_tool_use",
452 "matcher": { "match_regex": "[" },
453 "executor": { "type": "bash", "command": "true" }
454 })
455 })
456 .collect::<Vec<_>>();
457 let config = serde_json::json!({ "hooks": hooks });
458
459 let err = cap.validate_config(&config).unwrap_err();
460 assert!(
461 err.contains("maximum is") && !err.contains("regex"),
462 "{}",
463 err
464 );
465 }
466
467 #[test]
468 fn oversized_user_hooks_config_is_rejected() {
469 let cap = UserHooksCapability;
470 let config = serde_json::json!({
471 "hooks": [
472 {
473 "event": "pre_tool_use",
474 "description": "x".repeat(MAX_USER_HOOKS_CONFIG_BYTES),
475 "executor": { "type": "bash", "command": "true" }
476 }
477 ]
478 });
479
480 let err = cap.validate_config(&config).unwrap_err();
481 assert!(err.contains("maximum is"), "{}", err);
482 }
483
484 #[test]
485 fn matcher_on_non_tool_event_is_rejected_via_validate() {
486 let cap = UserHooksCapability;
487 let config = serde_json::json!({
488 "hooks": [
489 {
490 "event": "session_start",
491 "matcher": { "tool_name": "bash" },
492 "executor": { "type": "bash", "command": "true" }
493 }
494 ]
495 });
496 assert!(cap.validate_config(&config).is_err());
497 }
498
499 #[test]
500 fn uk_localization_and_schema_one_of_match_validation() {
501 let cap = UserHooksCapability;
502 assert_eq!(cap.localized_name(Some("uk-UA")), "Хуки користувача");
503 assert!(
504 cap.localized_description(Some("uk-UA"))
505 .contains("шелл-команди")
506 );
507 assert!(cap.describe_schema(Some("uk")).is_some());
508 assert!(cap.describe_schema(None).is_some());
509
510 let schema = cap.config_schema().expect("config schema");
512 let spec = &schema["$defs"]["UserHookSpec"]["properties"];
513 let consts = |prop: &serde_json::Value| -> Vec<String> {
514 prop["oneOf"]
515 .as_array()
516 .expect("oneOf")
517 .iter()
518 .map(|v| v["const"].as_str().expect("const").to_string())
519 .collect()
520 };
521 let events = consts(&spec["event"]);
522 let on_errors = consts(&spec["on_error"]);
523 assert_eq!(events.len(), 6);
524 assert_eq!(on_errors, vec!["block", "allow", "warn"]);
525 for event in &events {
526 for on_error in &on_errors {
527 let config = serde_json::json!({
528 "hooks": [
529 {
530 "event": event,
531 "executor": { "type": "bash", "command": "true" },
532 "on_error": on_error
533 }
534 ]
535 });
536 cap.validate_config(&config)
537 .unwrap_or_else(|e| panic!("{event}/{on_error} should validate: {e}"));
538 }
539 }
540 }
541
542 #[test]
543 fn missing_config_yields_no_specs() {
544 let cap = UserHooksCapability;
545 let empty = cap.user_hooks_with_config(&serde_json::Value::Null);
546 assert!(empty.is_empty());
547 let none = cap.user_hooks_with_config(&serde_json::json!({}));
548 assert!(none.is_empty());
549 }
550}