Skip to main content

everruns_core/
lifecycle_hooks.rs

1// User-defined lifecycle hooks: session and turn events.
2//
3// Two trait families bridge the four non-tool `HookEvent`s to runtime firing
4// points (see `specs/user-hooks.md`):
5//
6// - `SessionLifecycleHook` — `session_start` / `session_end`. Advisory only;
7//   a hook failure is logged and never aborts the session.
8// - `TurnLifecycleHook` — `user_prompt_submit` / `turn_end`. `turn_end` is
9//   advisory; `user_prompt_submit` can *block* (reject the inbound message and
10//   abort the turn) and *mutate* (rewrite the user message text).
11//
12// As with the tool hooks, capability authors hand the runtime *data*
13// (`UserHookSpec`); this module owns the spec -> `Arc<dyn …Hook>` translation
14// so the central timeout/output/sandbox limits stay enforced. The adapters
15// reuse the same `BashHookExecutor` + `BashHookDispatcher` as the pre/post
16// tool adapters, so payload delivery, output parsing, and `on_error` policy
17// are identical across every event.
18
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use serde_json::json;
23
24use crate::hook_executor::{BashHookExecutor, ExecutorOpts, HookExecutor, HookPayload};
25use crate::typed_id::{OrgId, SessionId, TurnId};
26use crate::user_hook_types::{ExecutorSpec, HookEvent, HookId, HookOutcome, OnError, UserHookSpec};
27
28// ============================================================================
29// Context structs
30// ============================================================================
31
32/// Context available when a session-lifecycle hook fires. Lighter than
33/// `ToolContext`: at session create/close there is no tool, no turn, and no
34/// per-tool stores — only the session identity and (optionally) the agent.
35#[derive(Debug, Clone)]
36pub struct SessionHookContext {
37    pub session_id: SessionId,
38    pub org_id: Option<OrgId>,
39    pub agent_id: Option<String>,
40}
41
42/// Context available when a turn-lifecycle hook fires.
43#[derive(Debug, Clone)]
44pub struct TurnHookContext {
45    pub session_id: SessionId,
46    pub turn_id: Option<TurnId>,
47    pub org_id: Option<OrgId>,
48    pub agent_id: Option<String>,
49}
50
51// ============================================================================
52// Decisions
53// ============================================================================
54
55/// Outcome of a `user_prompt_submit` chain. Mirrors `PreToolUseDecision` but
56/// over the user message text rather than a `ToolCall`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum UserPromptDecision {
59    /// Continue the turn with the (possibly rewritten) user message text.
60    Continue { message: String },
61    /// Reject the inbound message and abort the turn. `reason` is logged /
62    /// audited; `user_message` (if any) is surfaced to the caller.
63    Block {
64        reason: String,
65        user_message: Option<String>,
66    },
67}
68
69// ============================================================================
70// SessionLifecycleHook
71// ============================================================================
72
73/// Hook that fires on `session_start` or `session_end`. Advisory only — the
74/// runtime calls `fire` and logs any failure; it never blocks the session.
75#[async_trait]
76pub trait SessionLifecycleHook: Send + Sync {
77    /// Which event this hook is bound to (`SessionStart` or `SessionEnd`).
78    fn event(&self) -> HookEvent;
79    /// Stable id for logs.
80    fn hook_id(&self) -> &HookId;
81    /// Run the hook. The outcome is advisory; `Block`/`Mutate` are ignored
82    /// (logged) since these events cannot block or mutate anything.
83    async fn fire(&self, ctx: &SessionHookContext, data: serde_json::Value);
84}
85
86// ============================================================================
87// TurnLifecycleHook
88// ============================================================================
89
90/// Hook that fires on `user_prompt_submit` (blockable/mutable) or `turn_end`
91/// (advisory).
92#[async_trait]
93pub trait TurnLifecycleHook: Send + Sync {
94    fn event(&self) -> HookEvent;
95    fn hook_id(&self) -> &HookId;
96    /// The spec's `on_error` policy, used by the `user_prompt_submit` chain
97    /// runner to decide block-vs-continue on executor failure.
98    fn on_error(&self) -> OnError;
99    /// Run the hook against a payload. Returns the raw `HookOutcome`; chain
100    /// runners interpret it per-event (block/mutate for `user_prompt_submit`,
101    /// advisory for `turn_end`).
102    async fn run(&self, ctx: &TurnHookContext, data: serde_json::Value) -> HookOutcome;
103}
104
105// ============================================================================
106// Bash-backed adapters
107// ============================================================================
108
109/// Single adapter that implements both lifecycle traits over a bash executor.
110/// The `event` discriminates which trait method the runtime drives; the
111/// executor/limits/`on_error` handling is shared.
112struct BashLifecycleHook {
113    spec: UserHookSpec,
114    executor: Arc<dyn HookExecutor>,
115    opts: ExecutorOpts,
116    hook_id: HookId,
117}
118
119impl BashLifecycleHook {
120    fn new(spec: UserHookSpec, executor: Arc<dyn HookExecutor>, index: usize) -> Self {
121        let opts = ExecutorOpts {
122            timeout_ms: spec.timeout_ms,
123            max_output_bytes: 64 * 1024,
124        };
125        let hook_id = spec.resolve_id(index);
126        Self {
127            spec,
128            executor,
129            opts,
130            hook_id,
131        }
132    }
133
134    fn payload(&self, event: HookEvent, data: serde_json::Value) -> HookPayload {
135        HookPayload {
136            event,
137            hook_id: self.hook_id.clone(),
138            // session_id/org_id/turn_id are filled by the chain runner from
139            // the live context before dispatch; the nil default is overwritten
140            // there. (Lifecycle contexts vary by event, so the runner owns it.)
141            session_id: SessionId::from_uuid(uuid::Uuid::nil()),
142            turn_id: None,
143            org_id: None,
144            agent_id: None,
145            ts: chrono::Utc::now().to_rfc3339(),
146            data,
147        }
148    }
149}
150
151#[async_trait]
152impl SessionLifecycleHook for BashLifecycleHook {
153    fn event(&self) -> HookEvent {
154        self.spec.event
155    }
156    fn hook_id(&self) -> &HookId {
157        &self.hook_id
158    }
159    async fn fire(&self, ctx: &SessionHookContext, data: serde_json::Value) {
160        let mut payload = self.payload(self.spec.event, data);
161        payload.session_id = ctx.session_id;
162        payload.org_id = ctx.org_id;
163        payload.agent_id = ctx.agent_id.clone();
164        let outcome = self.executor.run(payload, &self.opts).await;
165        match outcome {
166            HookOutcome::Allow => {}
167            HookOutcome::Mutate { .. } | HookOutcome::Block { .. } => {
168                tracing::warn!(
169                    hook_id = %self.hook_id.as_str(),
170                    event = %self.spec.event.as_str(),
171                    "lifecycle hook returned block/mutate on an advisory event; ignoring"
172                );
173            }
174            HookOutcome::Error { message } => {
175                // Advisory: log per on_error but never abort the session.
176                tracing::warn!(
177                    hook_id = %self.hook_id.as_str(),
178                    event = %self.spec.event.as_str(),
179                    on_error = ?self.spec.on_error,
180                    message = %message,
181                    "lifecycle hook errored (advisory event, continuing)"
182                );
183            }
184        }
185    }
186}
187
188#[async_trait]
189impl TurnLifecycleHook for BashLifecycleHook {
190    fn event(&self) -> HookEvent {
191        self.spec.event
192    }
193    fn hook_id(&self) -> &HookId {
194        &self.hook_id
195    }
196    fn on_error(&self) -> OnError {
197        self.spec.on_error
198    }
199    async fn run(&self, ctx: &TurnHookContext, data: serde_json::Value) -> HookOutcome {
200        let mut payload = self.payload(self.spec.event, data);
201        payload.session_id = ctx.session_id;
202        payload.turn_id = ctx.turn_id.map(|t| t.to_string());
203        payload.org_id = ctx.org_id;
204        payload.agent_id = ctx.agent_id.clone();
205        self.executor.run(payload, &self.opts).await
206    }
207}
208
209// ============================================================================
210// Builders
211// ============================================================================
212
213fn build_bash_executor(
214    spec: &UserHookSpec,
215    dispatcher: Arc<dyn crate::hook_executor::BashHookDispatcher>,
216) -> Arc<dyn HookExecutor> {
217    match &spec.executor {
218        ExecutorSpec::Bash { command, env } => Arc::new(BashHookExecutor::with_dispatcher(
219            command.clone(),
220            env.clone(),
221            dispatcher,
222        )),
223    }
224}
225
226/// Build `SessionLifecycleHook` adapters for every spec whose event is
227/// `event` (must be `SessionStart` or `SessionEnd`). Invalid specs are dropped
228/// with a warning so one bad entry can't take down the chain.
229pub fn build_session_lifecycle_hooks(
230    specs: &[UserHookSpec],
231    event: HookEvent,
232    dispatcher: Arc<dyn crate::hook_executor::BashHookDispatcher>,
233) -> Vec<Arc<dyn SessionLifecycleHook>> {
234    debug_assert!(matches!(
235        event,
236        HookEvent::SessionStart | HookEvent::SessionEnd
237    ));
238    let mut out: Vec<Arc<dyn SessionLifecycleHook>> = Vec::new();
239    for (index, spec) in specs.iter().enumerate() {
240        if spec.event != event {
241            continue;
242        }
243        if let Err(e) = spec.validate() {
244            tracing::warn!(
245                hook_id = %spec.resolve_id(index).as_str(),
246                error = %e,
247                "skipping invalid lifecycle hook spec"
248            );
249            continue;
250        }
251        let executor = build_bash_executor(spec, dispatcher.clone());
252        out.push(Arc::new(BashLifecycleHook::new(
253            spec.clone(),
254            executor,
255            index,
256        )));
257    }
258    out
259}
260
261/// Build `TurnLifecycleHook` adapters for every spec whose event is `event`
262/// (must be `UserPromptSubmit` or `TurnEnd`).
263pub fn build_turn_lifecycle_hooks(
264    specs: &[UserHookSpec],
265    event: HookEvent,
266    dispatcher: Arc<dyn crate::hook_executor::BashHookDispatcher>,
267) -> Vec<Arc<dyn TurnLifecycleHook>> {
268    debug_assert!(matches!(
269        event,
270        HookEvent::UserPromptSubmit | HookEvent::TurnEnd
271    ));
272    let mut out: Vec<Arc<dyn TurnLifecycleHook>> = Vec::new();
273    for (index, spec) in specs.iter().enumerate() {
274        if spec.event != event {
275            continue;
276        }
277        if let Err(e) = spec.validate() {
278            tracing::warn!(
279                hook_id = %spec.resolve_id(index).as_str(),
280                error = %e,
281                "skipping invalid lifecycle hook spec"
282            );
283            continue;
284        }
285        let executor = build_bash_executor(spec, dispatcher.clone());
286        out.push(Arc::new(BashLifecycleHook::new(
287            spec.clone(),
288            executor,
289            index,
290        )));
291    }
292    out
293}
294
295// ============================================================================
296// Chain runners
297// ============================================================================
298
299/// Run every session-lifecycle hook in order. Advisory: failures are handled
300/// inside each `fire` call; this never returns an error.
301pub async fn run_session_lifecycle_hooks(
302    hooks: &[Arc<dyn SessionLifecycleHook>],
303    ctx: &SessionHookContext,
304    data: serde_json::Value,
305) {
306    for hook in hooks {
307        hook.fire(ctx, data.clone()).await;
308    }
309}
310
311/// Run the `turn_end` chain. Advisory: each hook runs independently; block /
312/// mutate outcomes are logged and ignored, errors are logged per `on_error`.
313pub async fn run_turn_end_hooks(
314    hooks: &[Arc<dyn TurnLifecycleHook>],
315    ctx: &TurnHookContext,
316    data: serde_json::Value,
317) {
318    for hook in hooks {
319        match hook.run(ctx, data.clone()).await {
320            HookOutcome::Allow => {}
321            HookOutcome::Mutate { .. } | HookOutcome::Block { .. } => {
322                tracing::warn!(
323                    hook_id = %hook.hook_id().as_str(),
324                    "turn_end hook returned block/mutate on an advisory event; ignoring"
325                );
326            }
327            HookOutcome::Error { message } => {
328                tracing::warn!(
329                    hook_id = %hook.hook_id().as_str(),
330                    message = %message,
331                    "turn_end hook errored (advisory, continuing)"
332                );
333            }
334        }
335    }
336}
337
338/// Run the `user_prompt_submit` chain sequentially over the message text.
339/// Each hook sees the previous hook's mutated text. The first `Block` wins and
340/// aborts the chain; on executor error the spec's `on_error` policy applies
341/// (`Block` -> block, `Warn`/`Allow` -> continue with current text).
342pub async fn run_user_prompt_submit_hooks(
343    hooks: &[Arc<dyn TurnLifecycleHook>],
344    ctx: &TurnHookContext,
345    mut message: String,
346) -> UserPromptDecision {
347    for hook in hooks {
348        let data = json!({ "message": message });
349        match hook.run(ctx, data).await {
350            HookOutcome::Allow => {}
351            HookOutcome::Mutate { patch, .. } => {
352                if let Some(new_msg) = patch.get("message").and_then(|v| v.as_str()) {
353                    message = new_msg.to_string();
354                }
355            }
356            HookOutcome::Block {
357                reason,
358                user_message,
359            } => {
360                return UserPromptDecision::Block {
361                    reason,
362                    user_message,
363                };
364            }
365            HookOutcome::Error { message: err } => match hook.on_error() {
366                OnError::Block => {
367                    return UserPromptDecision::Block {
368                        reason: format!("hook {} errored: {err}", hook.hook_id().as_str()),
369                        user_message: None,
370                    };
371                }
372                OnError::Warn => {
373                    tracing::warn!(
374                        hook_id = %hook.hook_id().as_str(),
375                        message = %err,
376                        "user_prompt_submit hook errored"
377                    );
378                }
379                OnError::Allow => {}
380            },
381        }
382    }
383    UserPromptDecision::Continue { message }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::hook_executor::{BashExecOutput, BashHookDispatcher};
390    use crate::user_hook_types::{HookMatcher, HookSource};
391    use async_trait::async_trait;
392    use std::collections::BTreeMap;
393
394    /// Dispatcher that returns a canned stdout/exit so we exercise the full
395    /// parse + decision path without a real sandbox.
396    struct CannedDispatcher {
397        stdout: String,
398        exit_code: i32,
399    }
400
401    #[async_trait]
402    impl BashHookDispatcher for CannedDispatcher {
403        async fn dispatch(
404            &self,
405            _payload: &HookPayload,
406            _command: &str,
407            _extra_env: &BTreeMap<String, String>,
408            _opts: &ExecutorOpts,
409        ) -> Result<BashExecOutput, String> {
410            Ok(BashExecOutput {
411                exit_code: self.exit_code,
412                stdout: self.stdout.clone(),
413                stderr: String::new(),
414            })
415        }
416    }
417
418    fn spec(event: HookEvent, on_error: OnError) -> UserHookSpec {
419        UserHookSpec {
420            id: Some("t".into()),
421            event,
422            matcher: HookMatcher::default(),
423            executor: ExecutorSpec::Bash {
424                command: "true".into(),
425                env: Default::default(),
426            },
427            timeout_ms: 5000,
428            on_error,
429            description: None,
430            source: HookSource::UserConfig,
431        }
432    }
433
434    fn dispatcher(stdout: &str, exit: i32) -> Arc<dyn BashHookDispatcher> {
435        Arc::new(CannedDispatcher {
436            stdout: stdout.into(),
437            exit_code: exit,
438        })
439    }
440
441    fn turn_ctx() -> TurnHookContext {
442        TurnHookContext {
443            session_id: SessionId::new(),
444            turn_id: Some(TurnId::new()),
445            org_id: None,
446            agent_id: None,
447        }
448    }
449
450    #[tokio::test]
451    async fn user_prompt_submit_allow_passes_message_through() {
452        let hooks = build_turn_lifecycle_hooks(
453            &[spec(HookEvent::UserPromptSubmit, OnError::Warn)],
454            HookEvent::UserPromptSubmit,
455            dispatcher("", 0),
456        );
457        let decision = run_user_prompt_submit_hooks(&hooks, &turn_ctx(), "hello".into()).await;
458        assert_eq!(
459            decision,
460            UserPromptDecision::Continue {
461                message: "hello".into()
462            }
463        );
464    }
465
466    #[tokio::test]
467    async fn user_prompt_submit_block_via_json() {
468        let hooks = build_turn_lifecycle_hooks(
469            &[spec(HookEvent::UserPromptSubmit, OnError::Warn)],
470            HookEvent::UserPromptSubmit,
471            dispatcher(
472                r#"{"decision":"block","reason":"nope","user_message":"blocked"}"#,
473                0,
474            ),
475        );
476        let decision = run_user_prompt_submit_hooks(&hooks, &turn_ctx(), "hello".into()).await;
477        match decision {
478            UserPromptDecision::Block {
479                reason,
480                user_message,
481            } => {
482                assert_eq!(reason, "nope");
483                assert_eq!(user_message.as_deref(), Some("blocked"));
484            }
485            other => panic!("expected Block, got {other:?}"),
486        }
487    }
488
489    #[tokio::test]
490    async fn user_prompt_submit_block_via_nonzero_exit() {
491        // Empty stdout + non-zero exit = block (git-hook fallback).
492        let hooks = build_turn_lifecycle_hooks(
493            &[spec(HookEvent::UserPromptSubmit, OnError::Warn)],
494            HookEvent::UserPromptSubmit,
495            dispatcher("", 1),
496        );
497        let decision = run_user_prompt_submit_hooks(&hooks, &turn_ctx(), "hello".into()).await;
498        assert!(matches!(decision, UserPromptDecision::Block { .. }));
499    }
500
501    #[tokio::test]
502    async fn user_prompt_submit_mutate_rewrites_message() {
503        let hooks = build_turn_lifecycle_hooks(
504            &[spec(HookEvent::UserPromptSubmit, OnError::Warn)],
505            HookEvent::UserPromptSubmit,
506            dispatcher(
507                r#"{"decision":"mutate","patch":{"message":"rewritten"}}"#,
508                0,
509            ),
510        );
511        let decision = run_user_prompt_submit_hooks(&hooks, &turn_ctx(), "original".into()).await;
512        assert_eq!(
513            decision,
514            UserPromptDecision::Continue {
515                message: "rewritten".into()
516            }
517        );
518    }
519
520    #[tokio::test]
521    async fn user_prompt_submit_error_with_on_error_block_blocks() {
522        // Non-JSON stdout => executor Error; on_error=Block => Block.
523        let hooks = build_turn_lifecycle_hooks(
524            &[spec(HookEvent::UserPromptSubmit, OnError::Block)],
525            HookEvent::UserPromptSubmit,
526            dispatcher("not json at all", 0),
527        );
528        let decision = run_user_prompt_submit_hooks(&hooks, &turn_ctx(), "hello".into()).await;
529        assert!(matches!(decision, UserPromptDecision::Block { .. }));
530    }
531
532    #[tokio::test]
533    async fn user_prompt_submit_error_with_on_error_warn_continues() {
534        let hooks = build_turn_lifecycle_hooks(
535            &[spec(HookEvent::UserPromptSubmit, OnError::Warn)],
536            HookEvent::UserPromptSubmit,
537            dispatcher("not json", 0),
538        );
539        let decision = run_user_prompt_submit_hooks(&hooks, &turn_ctx(), "hello".into()).await;
540        assert_eq!(
541            decision,
542            UserPromptDecision::Continue {
543                message: "hello".into()
544            }
545        );
546    }
547
548    #[tokio::test]
549    async fn user_prompt_submit_chain_threads_mutations_then_blocks() {
550        // First hook rewrites, second hook blocks. Order preserved.
551        let specs = [
552            {
553                let mut s = spec(HookEvent::UserPromptSubmit, OnError::Warn);
554                s.id = Some("rewriter".into());
555                s
556            },
557            {
558                let mut s = spec(HookEvent::UserPromptSubmit, OnError::Warn);
559                s.id = Some("blocker".into());
560                s
561            },
562        ];
563        // Both hooks share one dispatcher returning a block; the first hook's
564        // mutate vs block is decided by stdout, so to test threading we build
565        // them individually with different dispatchers.
566        let rewriter = build_turn_lifecycle_hooks(
567            &specs[..1],
568            HookEvent::UserPromptSubmit,
569            dispatcher(r#"{"decision":"mutate","patch":{"message":"step1"}}"#, 0),
570        );
571        let blocker = build_turn_lifecycle_hooks(
572            &specs[1..],
573            HookEvent::UserPromptSubmit,
574            dispatcher(r#"{"decision":"block","reason":"stop"}"#, 0),
575        );
576        let mut chain = rewriter;
577        chain.extend(blocker);
578        let decision = run_user_prompt_submit_hooks(&chain, &turn_ctx(), "orig".into()).await;
579        assert!(matches!(decision, UserPromptDecision::Block { .. }));
580    }
581
582    #[tokio::test]
583    async fn turn_end_runs_advisory_and_ignores_block() {
584        let hooks = build_turn_lifecycle_hooks(
585            &[spec(HookEvent::TurnEnd, OnError::Warn)],
586            HookEvent::TurnEnd,
587            // Even a "block" decision must not abort anything here.
588            dispatcher(r#"{"decision":"block","reason":"ignored"}"#, 0),
589        );
590        // Just assert it runs without panicking; advisory has no return value.
591        run_turn_end_hooks(&hooks, &turn_ctx(), json!({"success": true})).await;
592    }
593
594    #[tokio::test]
595    async fn session_lifecycle_runs_advisory() {
596        let hooks = build_session_lifecycle_hooks(
597            &[spec(HookEvent::SessionStart, OnError::Warn)],
598            HookEvent::SessionStart,
599            dispatcher("", 0),
600        );
601        let ctx = SessionHookContext {
602            session_id: SessionId::new(),
603            org_id: None,
604            agent_id: Some("agt_x".into()),
605        };
606        run_session_lifecycle_hooks(&hooks, &ctx, json!({"agent_id": "agt_x"})).await;
607    }
608
609    #[tokio::test]
610    async fn builders_filter_by_event() {
611        let specs = vec![
612            spec(HookEvent::SessionStart, OnError::Warn),
613            spec(HookEvent::SessionEnd, OnError::Warn),
614            spec(HookEvent::TurnEnd, OnError::Warn),
615            spec(HookEvent::UserPromptSubmit, OnError::Warn),
616        ];
617        let d = dispatcher("", 0);
618        assert_eq!(
619            build_session_lifecycle_hooks(&specs, HookEvent::SessionStart, d.clone()).len(),
620            1
621        );
622        assert_eq!(
623            build_session_lifecycle_hooks(&specs, HookEvent::SessionEnd, d.clone()).len(),
624            1
625        );
626        assert_eq!(
627            build_turn_lifecycle_hooks(&specs, HookEvent::TurnEnd, d.clone()).len(),
628            1
629        );
630        assert_eq!(
631            build_turn_lifecycle_hooks(&specs, HookEvent::UserPromptSubmit, d).len(),
632            1
633        );
634    }
635}