Skip to main content

agentyk_core/
hook.rs

1//! User hooks at stable agent lifecycle boundaries.
2//!
3//! Hooks are behavior-bearing values attached to an agent. The six events
4//! mirror Everruns' user-hook surface: session start/end, user prompt submit,
5//! pre/post tool use, and turn end. A hook may be an ordinary Rust value, a
6//! shell-backed implementation supplied by the `agentyk` facade, or an
7//! adapter owned by another host.
8//!
9//! This module is deliberately host-neutral. It defines payloads, matching,
10//! decisions, and the [`Hook`] trait, but never spawns a process. Executors
11//! belong in integration code because a durable or sandboxed host will run
12//! them differently from the in-process host.
13
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16
17use crate::id::{SessionId, TurnId};
18
19/// A stable lifecycle point at which a user hook can run.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum HookEvent {
23    /// Before the first turn executed by a newly created session.
24    SessionStart,
25    /// After a session is explicitly closed.
26    SessionEnd,
27    /// After a turn id is allocated but before its input enters history.
28    UserPromptSubmit,
29    /// Before one model-requested tool call is executed.
30    PreToolUse,
31    /// After one tool call returns and before its result enters history.
32    PostToolUse,
33    /// After a turn reaches a terminal outcome.
34    TurnEnd,
35}
36
37impl HookEvent {
38    /// Stable snake-case spelling used in payloads and configuration.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::SessionStart => "session_start",
42            Self::SessionEnd => "session_end",
43            Self::UserPromptSubmit => "user_prompt_submit",
44            Self::PreToolUse => "pre_tool_use",
45            Self::PostToolUse => "post_tool_use",
46            Self::TurnEnd => "turn_end",
47        }
48    }
49
50    /// Whether this event carries a tool name and arguments.
51    pub fn supports_matcher(self) -> bool {
52        matches!(self, Self::PreToolUse | Self::PostToolUse)
53    }
54
55    /// Whether a block decision has preventive meaning at this point.
56    pub fn can_block(self) -> bool {
57        matches!(self, Self::UserPromptSubmit | Self::PreToolUse)
58    }
59}
60
61/// Session and optional turn identity supplied to a hook.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub struct HookContext {
64    /// Session whose lifecycle is being intercepted.
65    pub session_id: SessionId,
66    /// Turn being intercepted, absent for session lifecycle events.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub turn_id: Option<TurnId>,
69}
70
71impl HookContext {
72    /// Context for a session-level event.
73    pub fn session(session_id: SessionId) -> Self {
74        Self {
75            session_id,
76            turn_id: None,
77        }
78    }
79
80    /// Context for an event inside a turn.
81    pub fn turn(session_id: SessionId, turn_id: TurnId) -> Self {
82        Self {
83            session_id,
84            turn_id: Some(turn_id),
85        }
86    }
87}
88
89/// Structured input delivered to one hook invocation.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct HookPayload {
92    /// Lifecycle point being fired.
93    pub event: HookEvent,
94    /// Stable id of the receiving hook.
95    pub hook_id: String,
96    /// Session and turn correlation. Flattened to top-level `session_id` and
97    /// `turn_id` fields on the JSON wire.
98    #[serde(flatten)]
99    pub context: HookContext,
100    /// Invocation timestamp.
101    pub ts: chrono::DateTime<chrono::Utc>,
102    /// Event-specific data. Its documented shape is part of the public hook
103    /// contract even though it stays JSON so executors can be language-neutral.
104    pub data: serde_json::Value,
105}
106
107impl HookPayload {
108    /// Build a payload for a particular hook.
109    pub fn new(
110        event: HookEvent,
111        hook_id: impl Into<String>,
112        context: HookContext,
113        data: serde_json::Value,
114    ) -> Self {
115        Self {
116            event,
117            hook_id: hook_id.into(),
118            context,
119            ts: chrono::Utc::now(),
120            data,
121        }
122    }
123}
124
125/// What a hook asks the engine to do.
126///
127/// Event-specific interpretation keeps the contract honest: prompt hooks may
128/// replace `message`, pre-tool hooks may patch `arguments`, and post-tool
129/// hooks may patch the result. Mutations and blocks returned at advisory
130/// session/turn-end events are ignored.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(tag = "decision", rename_all = "snake_case")]
133pub enum HookOutcome {
134    /// Continue without changing anything.
135    Allow,
136    /// Apply an event-specific JSON patch, then continue the chain.
137    Mutate {
138        /// Event-specific fields to replace or merge.
139        patch: serde_json::Value,
140        /// Optional diagnostic reason for audit surfaces.
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        reason: Option<String>,
143    },
144    /// Stop a blockable action. On advisory events this is ignored.
145    Block {
146        /// Diagnostic reason visible to the model or host.
147        reason: String,
148        /// Optional safer message for a user-facing surface.
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        user_message: Option<String>,
151    },
152    /// The executor itself failed.
153    Error {
154        /// Parse, timeout, sandbox, transport, or callback failure.
155        message: String,
156    },
157}
158
159/// Policy applied when a hook returns [`HookOutcome::Error`].
160#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum HookErrorPolicy {
163    /// Treat the error as a block at a blockable event.
164    Block,
165    /// Continue silently.
166    Allow,
167    /// Continue and emit a durable `hook.warning` custom event.
168    #[default]
169    Warn,
170}
171
172/// Restricted matcher for tool hook events.
173///
174/// This intentionally matches Everruns' useful subset rather than embedding
175/// a full JSONPath/glob language: exact or alternation/prefix tool names, a
176/// `$.field.subfield` argument path, and a Rust regular expression.
177#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(default)]
179pub struct HookMatcher {
180    /// Exact tool name.
181    pub tool_name: Option<String>,
182    /// `a|b|prefix*` alternation.
183    pub tool_name_glob: Option<String>,
184    /// Dot-only JSON path into tool arguments.
185    pub args_jsonpath: Option<String>,
186    /// Regex that must match the extracted value.
187    pub match_regex: Option<String>,
188    /// Alternate spelling for a security-oriented matching rule. Like
189    /// `match_regex`, the hook fires when it matches.
190    pub deny_regex: Option<String>,
191}
192
193impl HookMatcher {
194    /// Whether the matcher accepts a tool call.
195    pub fn matches(&self, tool_name: &str, arguments: &serde_json::Value) -> bool {
196        if let Some(exact) = &self.tool_name
197            && exact != tool_name
198        {
199            return false;
200        }
201        if let Some(pattern) = &self.tool_name_glob
202            && !pattern.split('|').any(|alternative| {
203                let alternative = alternative.trim();
204                alternative
205                    .strip_suffix('*')
206                    .map_or(alternative == tool_name, |prefix| {
207                        tool_name.starts_with(prefix)
208                    })
209            })
210        {
211            return false;
212        }
213
214        let extracted = self
215            .args_jsonpath
216            .as_deref()
217            .map(|path| extract_json_path(arguments, path));
218        match (
219            self.match_regex.as_deref(),
220            self.deny_regex.as_deref(),
221            extracted,
222        ) {
223            (None, None, None) => true,
224            (None, None, Some(value)) => !value.is_empty(),
225            (Some(pattern), None, Some(value)) | (None, Some(pattern), Some(value)) => {
226                regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&value))
227            }
228            _ => false,
229        }
230    }
231
232    /// Reject contradictory or incomplete matcher shapes before an agent runs.
233    pub fn validate(&self) -> std::result::Result<(), String> {
234        if self.match_regex.is_some() && self.deny_regex.is_some() {
235            return Err("match_regex and deny_regex are mutually exclusive".into());
236        }
237        if (self.match_regex.is_some() || self.deny_regex.is_some()) && self.args_jsonpath.is_none()
238        {
239            return Err("a regex matcher requires args_jsonpath".into());
240        }
241        for pattern in [&self.match_regex, &self.deny_regex].into_iter().flatten() {
242            regex::Regex::new(pattern)
243                .map_err(|error| format!("invalid hook matcher regex: {error}"))?;
244        }
245        Ok(())
246    }
247}
248
249fn extract_json_path(value: &serde_json::Value, path: &str) -> String {
250    let mut current = value;
251    for segment in path.strip_prefix("$.").unwrap_or(path).split('.') {
252        if segment.is_empty() {
253            continue;
254        }
255        let Some(next) = current.get(segment) else {
256            return String::new();
257        };
258        current = next;
259    }
260    match current {
261        serde_json::Value::String(value) => value.clone(),
262        serde_json::Value::Null => String::new(),
263        value => value.to_string(),
264    }
265}
266
267/// One user hook attached by value to an agent.
268#[async_trait]
269pub trait Hook: Send + Sync {
270    /// Stable diagnostic id, also delivered in [`HookPayload::hook_id`].
271    fn id(&self) -> &str;
272
273    /// Lifecycle point this hook receives.
274    fn event(&self) -> HookEvent;
275
276    /// Optional tool matcher. Ignored for non-tool events.
277    fn matcher(&self) -> Option<&HookMatcher> {
278        None
279    }
280
281    /// What the engine should do if execution returns an error.
282    fn on_error(&self) -> HookErrorPolicy {
283        HookErrorPolicy::Warn
284    }
285
286    /// Execute the hook against a structured payload.
287    async fn run(&self, payload: &HookPayload) -> HookOutcome;
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use serde_json::json;
294
295    #[test]
296    fn matcher_combines_name_path_and_regex() {
297        let matcher = HookMatcher {
298            tool_name_glob: Some("bash|edit_*".into()),
299            args_jsonpath: Some("$.command".into()),
300            match_regex: Some(r"^rm\s+-rf".into()),
301            ..HookMatcher::default()
302        };
303        assert!(matcher.matches("bash", &json!({"command": "rm -rf build"})));
304        assert!(!matcher.matches("read_file", &json!({"command": "rm -rf build"})));
305        assert!(!matcher.matches("bash", &json!({"command": "cargo test"})));
306    }
307
308    #[test]
309    fn matcher_validation_rejects_ambiguous_regexes() {
310        let matcher = HookMatcher {
311            args_jsonpath: Some("$.command".into()),
312            match_regex: Some("x".into()),
313            deny_regex: Some("y".into()),
314            ..HookMatcher::default()
315        };
316        assert!(matcher.validate().is_err());
317    }
318}