1use 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum UserPromptDecision {
59 Continue { message: String },
61 Block {
64 reason: String,
65 user_message: Option<String>,
66 },
67}
68
69#[async_trait]
76pub trait SessionLifecycleHook: Send + Sync {
77 fn event(&self) -> HookEvent;
79 fn hook_id(&self) -> &HookId;
81 async fn fire(&self, ctx: &SessionHookContext, data: serde_json::Value);
84}
85
86#[async_trait]
93pub trait TurnLifecycleHook: Send + Sync {
94 fn event(&self) -> HookEvent;
95 fn hook_id(&self) -> &HookId;
96 fn on_error(&self) -> OnError;
99 async fn run(&self, ctx: &TurnHookContext, data: serde_json::Value) -> HookOutcome;
103}
104
105struct 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: 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 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
209fn 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
226pub 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
261pub 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
295pub 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
311pub 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
338pub 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 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 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 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 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 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 dispatcher(r#"{"decision":"block","reason":"ignored"}"#, 0),
589 );
590 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}