Skip to main content

everruns_core/capabilities/
user_hooks.rs

1// User Hooks capability — user-config-authored hook entries + capability-bundle
2// muting. See `specs/user-hooks.md` for the design.
3//
4// This capability has no tools and no system prompt. Its single job is to
5// surface `UserHookSpec` entries from its config (the user's own hook list)
6// to the central `HookAdapterBuilder`, and to carry the
7// `disabled_contributions` list that mutes capability-bundled hooks.
8//
9// Risk: High. Even when the user supplies no entries of their own, accepting
10// arbitrary commands from config is a code-execution surface; enabling the
11// capability is therefore an admin-only action through the existing
12// `check_high_risk_caps` gate.
13
14use serde::{Deserialize, Serialize};
15
16use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
17use crate::user_hook_types::{HookSource, UserHookSpec};
18
19/// Config shape parsed from `AgentCapabilityConfig.config` for the
20/// `user_hooks` capability. Both fields default to empty so an
21/// unconfigured-but-enabled instance is a legal no-op.
22#[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        // Accepts arbitrary bash commands from config. Even though execution
64        // is sandboxed through bashkit_shell, the assignment gate must stay
65        // admin-only — anyone who can configure this capability can run
66        // arbitrary code in the session sandbox on every agent action.
67        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        // Empty/null is valid: the capability becomes a pass-through that
202        // only applies `disabled_contributions` (which is also empty).
203        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        // Stamp source = UserConfig on each entry so resolve_id picks the
364        // `user:` namespace.
365        parsed
366            .hooks
367            .into_iter()
368            .map(|mut hook| {
369                hook.source = HookSource::UserConfig;
370                hook
371            })
372            .collect()
373    }
374}
375
376/// Read the `disabled_contributions` list out of the `user_hooks` capability's
377/// config. Exposed as a free function so the collection pipeline can apply it
378/// without round-tripping through the capability trait.
379pub 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    // Metadata constants covered by builtin_capabilities_satisfy_registry_invariants.
394
395    #[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        // Every event/on_error oneOf const must be accepted by validate_config.
511        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}