Skip to main content

talos_plugin/
registry.rs

1//! Hook registry and dispatch runtime.
2
3use std::collections::HashMap;
4use std::panic::AssertUnwindSafe;
5use std::sync::Arc;
6
7use futures_util::FutureExt;
8use tokio::time::timeout;
9
10use crate::error::HookError;
11use crate::event::{HookEvent, HookEventKind};
12use crate::handler::{HookContext, HookHandler, HookResult};
13
14/// Read-only diagnostic view of a registered hook handler.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct HookRegistration {
17    /// Event kind the handler subscribed to.
18    pub event: HookEventKind,
19    /// Stable handler name.
20    pub handler: String,
21}
22
23/// Final outcome of dispatching a hook event through the registry.
24#[derive(Debug)]
25pub enum HookOutcome<'a> {
26    /// Dispatch completed normally.
27    Continue(HookEvent<'a>),
28    /// A handler requested that the wrapped action be skipped.
29    Skip(HookEvent<'a>),
30    /// A handler denied the wrapped action.
31    Deny {
32        /// Event state when dispatch stopped.
33        event: HookEvent<'a>,
34        /// Denial reason.
35        reason: String,
36    },
37}
38
39impl<'a> HookOutcome<'a> {
40    /// Returns the final event state.
41    #[must_use]
42    pub fn into_event(self) -> HookEvent<'a> {
43        match self {
44            Self::Continue(event) | Self::Skip(event) => event,
45            Self::Deny { event, .. } => event,
46        }
47    }
48}
49
50/// Per-agent hook registry.
51#[derive(Default)]
52pub struct HookRegistry {
53    handlers: HashMap<HookEventKind, Vec<Arc<dyn HookHandler>>>,
54}
55
56impl HookRegistry {
57    /// Creates an empty registry.
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Registers a handler for all of its subscribed event kinds.
64    pub fn register(&mut self, handler: Arc<dyn HookHandler>) {
65        for kind in handler.subscribed() {
66            self.handlers
67                .entry(*kind)
68                .or_default()
69                .push(handler.clone());
70        }
71    }
72
73    /// Returns the number of handlers registered for an event kind.
74    #[must_use]
75    pub fn handlers_for(&self, kind: HookEventKind) -> usize {
76        self.handlers.get(&kind).map_or(0, Vec::len)
77    }
78
79    /// Returns a read-only diagnostic snapshot of registered handlers.
80    #[must_use]
81    pub fn registrations(&self) -> Vec<HookRegistration> {
82        let mut registrations = Vec::new();
83        for kind in crate::event::ALL_HOOK_EVENT_KINDS {
84            if let Some(handlers) = self.handlers.get(&kind) {
85                for handler in handlers {
86                    registrations.push(HookRegistration {
87                        event: kind,
88                        handler: handler.name().to_string(),
89                    });
90                }
91            }
92        }
93        registrations
94    }
95
96    /// Dispatches a hook event sequentially to all subscribed handlers.
97    pub async fn dispatch<'a>(&self, ctx: &HookContext, event: HookEvent<'a>) -> HookOutcome<'a> {
98        self.dispatch_inner(ctx, event, false).await
99    }
100
101    /// Dispatches the final permission gate with fail-closed failure semantics.
102    ///
103    /// Unlike general lifecycle dispatch, `Skip`, panic, and timeout deny the operation. Callers
104    /// may execute only when this method returns [`HookOutcome::Continue`].
105    pub async fn dispatch_permission_gate<'a>(
106        &self,
107        ctx: &HookContext,
108        event: HookEvent<'a>,
109    ) -> HookOutcome<'a> {
110        debug_assert!(event.is_permission_boundary());
111        self.dispatch_inner(ctx, event, true).await
112    }
113
114    async fn dispatch_inner<'a>(
115        &self,
116        ctx: &HookContext,
117        mut event: HookEvent<'a>,
118        fail_closed: bool,
119    ) -> HookOutcome<'a> {
120        let kind = event.kind();
121        let Some(handlers) = self.handlers.get(&kind) else {
122            return HookOutcome::Continue(event);
123        };
124
125        for handler in handlers {
126            let handler_name = handler.name().to_owned();
127            let handler_future = AssertUnwindSafe(handler.on_event(ctx, &mut event)).catch_unwind();
128
129            match timeout(handler.timeout(), handler_future).await {
130                Ok(Ok(HookResult::Continue)) => {}
131                Ok(Ok(HookResult::Skip)) if fail_closed => {
132                    return HookOutcome::Deny {
133                        event,
134                        reason: format!("permission hook '{handler_name}' skipped final gate"),
135                    };
136                }
137                Ok(Ok(HookResult::Skip)) => return HookOutcome::Skip(event),
138                Ok(Ok(HookResult::Deny { reason })) => {
139                    let error = HookError::Denied {
140                        handler: handler_name,
141                        reason: reason.clone(),
142                    };
143                    tracing::warn!(error = %error, event = %kind, "hook denied event");
144                    return HookOutcome::Deny { event, reason };
145                }
146                Ok(Ok(HookResult::Modify(modified))) => {
147                    if event.is_permission_boundary() {
148                        tracing::error!(
149                            handler = %handler.name(),
150                            event = %kind,
151                            "hook modify ignored for permission boundary"
152                        );
153                    } else {
154                        event = modified;
155                    }
156                }
157                Ok(Err(_)) => {
158                    let error = HookError::Panic {
159                        handler: handler_name,
160                    };
161                    tracing::error!(error = %error, event = %kind, "hook panicked; aborting hook chain");
162                    if fail_closed {
163                        return HookOutcome::Deny {
164                            event,
165                            reason: "permission hook panicked".to_owned(),
166                        };
167                    }
168                    return HookOutcome::Continue(event);
169                }
170                Err(_) => {
171                    let error = HookError::Timeout {
172                        handler: handler_name,
173                        timeout: handler.timeout(),
174                    };
175                    tracing::warn!(error = %error, event = %kind, "hook timed out");
176                    if fail_closed {
177                        return HookOutcome::Deny {
178                            event,
179                            reason: "permission hook timed out".to_owned(),
180                        };
181                    }
182                }
183            }
184        }
185
186        HookOutcome::Continue(event)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::event::TurnId;
194    use crate::handler::HookContext;
195    use async_trait::async_trait;
196    use std::time::Duration;
197
198    struct DiagnosticHook;
199
200    #[async_trait]
201    impl HookHandler for DiagnosticHook {
202        fn name(&self) -> &str {
203            "diagnostic"
204        }
205
206        fn subscribed(&self) -> &'static [HookEventKind] {
207            &[HookEventKind::TurnStart, HookEventKind::TurnComplete]
208        }
209
210        fn timeout(&self) -> Duration {
211            Duration::from_millis(10)
212        }
213
214        async fn on_event(&self, _ctx: &HookContext, _event: &mut HookEvent<'_>) -> HookResult {
215            HookResult::Continue
216        }
217    }
218
219    #[test]
220    fn pre_filter_by_kind() {
221        let registry = HookRegistry::new();
222        let ctx = HookContext::new(TurnId::new(), std::path::PathBuf::from("."));
223        let runtime = tokio::runtime::Builder::new_current_thread()
224            .enable_time()
225            .build()
226            .expect("runtime");
227        runtime.block_on(async {
228            let outcome = registry
229                .dispatch(
230                    &ctx,
231                    HookEvent::TurnStart {
232                        turn_id: ctx.turn_id,
233                    },
234                )
235                .await;
236            assert!(matches!(
237                outcome,
238                HookOutcome::Continue(HookEvent::TurnStart { .. })
239            ));
240        });
241    }
242
243    #[test]
244    fn registrations_reports_handlers_without_dispatch() {
245        let mut registry = HookRegistry::new();
246        registry.register(Arc::new(DiagnosticHook));
247
248        let registrations = registry.registrations();
249
250        assert_eq!(registrations.len(), 2);
251        assert_eq!(
252            registrations[0],
253            HookRegistration {
254                event: HookEventKind::TurnStart,
255                handler: "diagnostic".to_string(),
256            }
257        );
258        assert_eq!(
259            registrations[1],
260            HookRegistration {
261                event: HookEventKind::TurnComplete,
262                handler: "diagnostic".to_string(),
263            }
264        );
265    }
266}