harness_loop/hooks/
mod.rs1pub 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#[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 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 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 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 tracing::debug!(
70 hook = h.name(),
71 event = ev.name(),
72 "hook mutation ignored (not yet wired)"
73 );
74 }
75 HookOutcome::Allow => {}
76 _ => {
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}