Skip to main content

harness_loop/hooks/
mod.rs

1//! `HookBus` — dispatch the lifecycle events to registered [`Hook`]s.
2//!
3//! Per DESIGN.md §10, a hook is a *cheap* synchronous policy that decides what
4//! happens around tool calls, model calls, compaction, sensor runs, and so on.
5//! Long async work belongs in a sensor or tool, not a hook.
6
7pub mod audit;
8pub mod broadcast;
9pub use audit::{
10    ACTOR_KEY, AuditHook, AuditRecord, AuditSink, ChainVerification, ChainedRecord, HashChainSink,
11    JsonlAuditSink, REQUEST_KEY, SESSION_KEY, new_request_id, verify_chain,
12};
13
14use harness_core::{Event, Hook, HookOutcome, World, iter_macro_hooks};
15use std::sync::Arc;
16
17/// Ordered list of hooks; fires events through every match.
18#[derive(Default)]
19pub struct HookBus {
20    hooks: Vec<Arc<dyn Hook>>,
21}
22
23impl HookBus {
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Pull in every `#[hook]`-registered hook.
29    pub fn with_macro_hooks(mut self) -> Self {
30        for h in iter_macro_hooks() {
31            self.hooks.push(h);
32        }
33        self
34    }
35
36    /// Consume + return (used by `AgentLoop::with_macro_hooks`).
37    pub fn with_macro_hooks_take(self) -> Self {
38        self.with_macro_hooks()
39    }
40
41    pub fn register(&mut self, h: Arc<dyn Hook>) {
42        self.hooks.push(h);
43    }
44
45    pub fn len(&self) -> usize {
46        self.hooks.len()
47    }
48    pub fn is_empty(&self) -> bool {
49        self.hooks.is_empty()
50    }
51
52    /// Fire `ev` through all matching hooks in registration order.
53    ///
54    /// Aggregation:
55    /// - First `Deny` short-circuits, return the `Deny`.
56    /// - All `Inject` payloads concatenate; if any present and no Deny, return `Inject(joined)`.
57    /// - Otherwise `Allow`.
58    pub fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
59        let mut injects = Vec::<String>::new();
60        for h in self.hooks.iter().filter(|h| h.matches(ev)) {
61            match h.fire(ev, world) {
62                HookOutcome::Deny { reason } => {
63                    tracing::warn!(hook = h.name(), event = ev.name(), %reason, "hook denied");
64                    return HookOutcome::Deny { reason };
65                }
66                HookOutcome::Inject(s) => injects.push(s),
67                HookOutcome::Mutate(_) => {
68                    // Mutation semantics are event-specific and not yet honoured by the runtime.
69                    tracing::debug!(
70                        hook = h.name(),
71                        event = ev.name(),
72                        "hook mutation ignored (not yet wired)"
73                    );
74                }
75                HookOutcome::Allow => {}
76                // HookOutcome is `#[non_exhaustive]`; treat any future variant as Allow.
77                _ => {
78                    tracing::warn!(
79                        hook = h.name(),
80                        "unrecognised HookOutcome variant — treating as Allow"
81                    );
82                }
83            }
84        }
85        if injects.is_empty() {
86            HookOutcome::Allow
87        } else {
88            HookOutcome::Inject(injects.join("\n"))
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use harness_core::{Event, World};
97
98    struct AlwaysDeny;
99    impl Hook for AlwaysDeny {
100        fn name(&self) -> &str {
101            "always-deny"
102        }
103        fn matches(&self, _: &Event<'_>) -> bool {
104            true
105        }
106        fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
107            HookOutcome::Deny {
108                reason: "nope".into(),
109            }
110        }
111    }
112
113    struct Counter(std::sync::atomic::AtomicU32);
114    impl Hook for Counter {
115        fn name(&self) -> &str {
116            "counter"
117        }
118        fn matches(&self, _: &Event<'_>) -> bool {
119            true
120        }
121        fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
122            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
123            HookOutcome::Allow
124        }
125    }
126
127    fn mock_world() -> World {
128        use harness_core::{Clock, KvStore, ProcessOutput, ProcessRunner, RepoView};
129        use std::path::Path;
130
131        struct NoopClock;
132        impl Clock for NoopClock {
133            fn now_ms(&self) -> i64 {
134                0
135            }
136        }
137        struct NoopRunner;
138        #[async_trait::async_trait]
139        impl ProcessRunner for NoopRunner {
140            async fn exec(
141                &self,
142                _: &str,
143                _: &[&str],
144                _: Option<&Path>,
145            ) -> std::io::Result<ProcessOutput> {
146                Ok(ProcessOutput {
147                    status: 0,
148                    stdout: String::new(),
149                    stderr: String::new(),
150                })
151            }
152        }
153        struct NoopKv;
154        #[async_trait::async_trait]
155        impl KvStore for NoopKv {
156            async fn get(&self, _: &str) -> Option<Vec<u8>> {
157                None
158            }
159            async fn set(&self, _: &str, _: Vec<u8>) {}
160            async fn delete(&self, _: &str) {}
161        }
162
163        World {
164            repo: RepoView { root: ".".into() },
165            runner: Arc::new(NoopRunner),
166            clock: Arc::new(NoopClock),
167            kv: Arc::new(NoopKv),
168            profile: harness_core::UserProfile::default(),
169            session: None,
170        }
171    }
172
173    #[test]
174    fn deny_short_circuits() {
175        let counter = Arc::new(Counter(0.into()));
176        let mut bus = HookBus::new();
177        bus.register(Arc::new(AlwaysDeny));
178        bus.register(counter.clone());
179        let mut world = mock_world();
180        let outcome = bus.fire(&Event::Stop, &mut world);
181        assert!(matches!(outcome, HookOutcome::Deny { .. }));
182        assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 0);
183    }
184
185    #[test]
186    fn all_match_fire_in_order() {
187        let counter = Arc::new(Counter(0.into()));
188        let mut bus = HookBus::new();
189        bus.register(counter.clone());
190        bus.register(counter.clone());
191        bus.register(counter.clone());
192        let mut world = mock_world();
193        let outcome = bus.fire(&Event::Stop, &mut world);
194        assert!(matches!(outcome, HookOutcome::Allow));
195        assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 3);
196    }
197}