1pub mod events;
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use futures::future::join_all;
15use tokio::sync::RwLock;
16
17use self::events::{HookEvent, HookKind, HookResult};
18use crate::extensions::manifest::HookMatcher;
19use crate::extensions::permissions::{Permission, PermissionSet};
20
21const HANDLER_TIMEOUT: Duration = Duration::from_secs(5);
23
24fn extensions_trace_enabled() -> bool {
25 std::env::var("SYNAPS_EXTENSIONS_TRACE")
26 .map(|value| {
27 let normalized = value.trim().to_ascii_lowercase();
28 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
29 })
30 .unwrap_or(false)
31}
32
33fn hook_result_action(result: &HookResult) -> &'static str {
34 match result {
35 HookResult::Continue => "continue",
36 HookResult::Block { .. } => "block",
37 HookResult::Inject { .. } => "inject",
38 HookResult::Confirm { .. } => "confirm",
39 HookResult::Modify { .. } => "modify",
40 HookResult::Replace { .. } => "replace",
41 }
42}
43
44#[derive(Clone)]
46pub struct HandlerRegistration {
47 pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
49 pub tool_filter: Option<String>,
51 pub matcher: Option<HookMatcher>,
53 pub permissions: PermissionSet,
55}
56
57pub struct HookBus {
62 handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
63 session_injection: RwLock<Option<String>>,
72}
73
74impl HookBus {
75 pub fn new() -> Self {
77 Self {
78 handlers: RwLock::new(HashMap::new()),
79 session_injection: RwLock::new(None),
80 }
81 }
82
83 pub async fn set_session_injection(&self, content: String) {
88 *self.session_injection.write().await = Some(content);
89 }
90
91 pub async fn session_injection(&self) -> Option<String> {
94 self.session_injection.read().await.clone()
95 }
96
97 pub async fn subscribe(
102 &self,
103 kind: HookKind,
104 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
105 tool_filter: Option<String>,
106 matcher: Option<HookMatcher>,
107 permissions: PermissionSet,
108 ) -> Result<(), String> {
109 if !permissions.allows_hook(kind) {
111 return Err(format!(
112 "Extension '{}' lacks permission '{}' required for hook '{}'",
113 handler.id(),
114 kind.required_permission().as_str(),
115 kind.as_str(),
116 ));
117 }
118
119 let reg = HandlerRegistration {
120 handler,
121 tool_filter,
122 matcher,
123 permissions,
124 };
125
126 let mut handlers = self.handlers.write().await;
127 handlers.entry(kind).or_default().push(reg);
128 Ok(())
129 }
130
131 pub async fn emit(&self, event: &HookEvent) -> HookResult {
139 let registrations = {
144 let handlers = self.handlers.read().await;
145 match handlers.get(&event.kind) {
146 Some(regs) if !regs.is_empty() => regs.clone(),
147 _ => return HookResult::Continue, }
149 }; let mut injections: Vec<String> = Vec::new();
153
154 for reg in ®istrations {
155 if let Some(ref filter) = reg.tool_filter {
159 let matches = match (&event.tool_name, &event.tool_runtime_name) {
160 (Some(api), Some(runtime)) => filter == api || filter == runtime,
161 (Some(api), None) => filter == api,
162 (None, Some(runtime)) => filter == runtime,
163 (None, None) => false,
164 };
165 if !matches {
166 continue;
167 }
168 }
169
170 if let Some(ref matcher) = reg.matcher {
171 if !matcher.matches(event) {
172 continue;
173 }
174 }
175
176 let handler = reg.handler.clone();
178 let event_clone = event.clone();
179 let trace_enabled = extensions_trace_enabled();
180 let started_at = trace_enabled.then(Instant::now);
181 let result = tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await;
182
183 if trace_enabled {
184 let health = reg.handler.health().await;
185 let health = health.as_str();
186 let restart_count = reg.handler.restart_count().await;
187 let duration_ms = started_at
188 .map(|start| start.elapsed().as_millis() as u64)
189 .unwrap_or(0);
190 match &result {
191 Ok(hook_result) => {
192 let action = hook_result_action(hook_result);
193 tracing::info!(
194 extension_trace = true,
195 hook = %event.kind.as_str(),
196 extension = %reg.handler.id(),
197 action = action,
198 duration_ms = duration_ms,
199 health = health,
200 restart_count = restart_count,
201 "Extension hook trace"
202 );
203 }
204 Err(_) => {
205 tracing::warn!(
206 extension_trace = true,
207 hook = %event.kind.as_str(),
208 extension = %reg.handler.id(),
209 action = "timeout",
210 duration_ms = duration_ms,
211 timeout_secs = HANDLER_TIMEOUT.as_secs(),
212 health = health,
213 restart_count = restart_count,
214 "Extension hook trace"
215 );
216 }
217 }
218 }
219
220 match result {
221 Ok(result) if !event.kind.allows_result(&result) => {
222 tracing::warn!(
223 hook = %event.kind.as_str(),
224 extension = %reg.handler.id(),
225 action = hook_result_action(&result),
226 "Extension returned action not allowed for hook — ignoring"
227 );
228 continue;
229 }
230 Ok(HookResult::Block { reason }) => {
231 tracing::info!(
232 hook = %event.kind.as_str(),
233 extension = %reg.handler.id(),
234 reason = %reason,
235 "Hook blocked by extension"
236 );
237 return HookResult::Block { reason };
238 }
239 Ok(HookResult::Continue) => {}
240 Ok(HookResult::Inject { content }) => {
241 tracing::debug!(
242 hook = %event.kind.as_str(),
243 extension = %reg.handler.id(),
244 len = content.len(),
245 "Extension injected context"
246 );
247 injections.push(content);
249 }
250 Ok(HookResult::Modify { input }) => {
251 tracing::info!(
252 hook = %event.kind.as_str(),
253 extension = %reg.handler.id(),
254 "Hook modified tool input by extension"
255 );
256 return HookResult::Modify { input };
257 }
258 Ok(HookResult::Replace { output }) => {
259 if !reg.permissions.has(Permission::ToolsTransformOutput) {
265 tracing::warn!(
266 hook = %event.kind.as_str(),
267 extension = %reg.handler.id(),
268 "Extension returned Replace without tools.transform_output permission — ignoring transform"
269 );
270 continue;
271 }
272 let observed_len = event.tool_output.as_ref().map_or(0, String::len);
273 tracing::info!(
274 hook = %event.kind.as_str(),
275 extension = %reg.handler.id(),
276 observed_len,
277 replacement_len = output.len(),
278 "Hook replaced tool output by extension"
279 );
280 let _ = crate::extensions::audit::record_tool_output_replace(
284 reg.handler.id(),
285 event.kind.as_str(),
286 event.tool_name.clone(),
287 observed_len,
288 output.len(),
289 );
290 return HookResult::Replace { output };
292 }
293 Ok(HookResult::Confirm { message }) => {
294 tracing::info!(
295 hook = %event.kind.as_str(),
296 extension = %reg.handler.id(),
297 "Hook requested confirmation by extension"
298 );
299 return HookResult::Confirm { message };
300 }
301 Err(_timeout) => {
302 tracing::warn!(
303 hook = %event.kind.as_str(),
304 extension = %reg.handler.id(),
305 timeout_secs = HANDLER_TIMEOUT.as_secs(),
306 "Hook handler timed out — skipping"
307 );
308 }
310 }
311 }
312
313 if !injections.is_empty() {
315 HookResult::Inject {
316 content: injections.join("\n\n"),
317 }
318 } else {
319 HookResult::Continue
320 }
321 }
322
323 pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
347 assert!(
350 !matches!(
351 event.kind,
352 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
353 ),
354 "emit_concurrent must not be used for transform-capable hooks \
355 ({:?}); their Block/Modify/Replace/Inject results need the \
356 deterministic first-wins ordering of the sequential emit()",
357 event.kind,
358 );
359 let registrations = {
361 let handlers = self.handlers.read().await;
362 match handlers.get(&event.kind) {
363 Some(regs) if !regs.is_empty() => regs.clone(),
364 _ => return HookResult::Continue, }
366 };
367
368 let futures: Vec<_> =
370 registrations
371 .iter()
372 .filter(|reg| {
373 if let Some(ref filter) = reg.tool_filter {
375 match (&event.tool_name, &event.tool_runtime_name) {
376 (Some(api), Some(runtime)) => filter == api || filter == runtime,
377 (Some(api), None) => filter == api,
378 (None, Some(runtime)) => filter == runtime,
379 (None, None) => false,
380 }
381 } else {
382 true
383 }
384 })
385 .filter(|reg| reg.matcher.as_ref().map_or(true, |m| m.matches(event)))
386 .map(|reg| {
387 let handler = reg.handler.clone();
388 let event_clone = event.clone();
389 async move {
390 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await
391 }
392 })
393 .collect();
394
395 let results = join_all(futures).await;
396
397 let mut injections: Vec<String> = Vec::new();
398 for result in results {
399 match result {
400 Ok(HookResult::Continue) => {}
401 Ok(HookResult::Block { reason }) => {
402 return HookResult::Block { reason };
403 }
404 Ok(HookResult::Inject { content }) => {
405 injections.push(content);
406 }
407 Ok(HookResult::Modify { input }) => {
408 return HookResult::Modify { input };
409 }
410 Ok(HookResult::Replace { output }) => {
411 return HookResult::Replace { output };
412 }
413 Ok(HookResult::Confirm { message }) => {
414 return HookResult::Confirm { message };
415 }
416 Err(_timeout) => {
417 tracing::warn!(
418 hook = %event.kind.as_str(),
419 timeout_secs = HANDLER_TIMEOUT.as_secs(),
420 "Hook handler timed out in concurrent emit — skipping"
421 );
422 }
423 }
424 }
425
426 if !injections.is_empty() {
427 HookResult::Inject {
428 content: injections.join("\n\n"),
429 }
430 } else {
431 HookResult::Continue
432 }
433 }
434
435 pub async fn unsubscribe_all(&self, extension_id: &str) {
437 let mut handlers = self.handlers.write().await;
438 for regs in handlers.values_mut() {
439 regs.retain(|r| r.handler.id() != extension_id);
440 }
441 }
442
443 pub async fn handler_count(&self) -> usize {
445 let handlers = self.handlers.read().await;
446 handlers.values().map(|v| v.len()).sum()
447 }
448
449 pub async fn is_empty(&self) -> bool {
451 let handlers = self.handlers.read().await;
452 handlers.values().all(|v| v.is_empty())
453 }
454
455 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
458 let handlers = self.handlers.read().await;
459 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
460 for (kind, regs) in handlers.iter() {
461 for reg in regs {
462 if reg.handler.id() == extension_id {
463 out.push((*kind, reg.tool_filter.clone()));
464 }
465 }
466 }
467 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
468 out
469 }
470}
471
472impl Default for HookBus {
473 fn default() -> Self {
474 Self::new()
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::extensions::hooks::events::HookEvent;
482 use crate::extensions::permissions::Permission;
483 use async_trait::async_trait;
484 use std::sync::atomic::{AtomicUsize, Ordering};
485
486 struct TestHandler {
488 id: String,
489 call_count: AtomicUsize,
490 result: HookResult,
491 }
492
493 impl TestHandler {
494 fn new(id: &str, result: HookResult) -> Arc<Self> {
495 Arc::new(Self {
496 id: id.to_string(),
497 call_count: AtomicUsize::new(0),
498 result,
499 })
500 }
501
502 fn calls(&self) -> usize {
503 self.call_count.load(Ordering::Relaxed)
504 }
505 }
506
507 #[async_trait]
508 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
509 fn id(&self) -> &str {
510 &self.id
511 }
512
513 async fn handle(&self, _event: &HookEvent) -> HookResult {
514 self.call_count.fetch_add(1, Ordering::Relaxed);
515 self.result.clone()
516 }
517
518 async fn shutdown(&self) {}
519 }
520
521 fn perms_with(perms: &[Permission]) -> PermissionSet {
522 let mut set = PermissionSet::new();
523 for p in perms {
524 set.grant(*p);
525 }
526 set
527 }
528
529 #[tokio::test]
533 async fn after_tool_call_replace_substitutes_recorded_output() {
534 let bus = std::sync::Arc::new(HookBus::new());
535 let handler = TestHandler::new(
536 "compressor",
537 HookResult::Replace {
538 output: "COMPRESSED".into(),
539 },
540 );
541 bus.subscribe(
542 HookKind::AfterToolCall,
543 handler,
544 None,
545 None,
546 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
547 )
548 .await
549 .unwrap();
550
551 let recorded = crate::runtime::emit_after_tool_call(
552 &bus,
553 "bash",
554 None,
555 serde_json::json!({"command": "cat huge.log"}),
556 "RAW 10k lines".to_string(),
557 30_000,
558 )
559 .await;
560
561 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
562 }
563
564 #[tokio::test]
566 async fn after_tool_call_without_replace_keeps_original_output() {
567 let bus = std::sync::Arc::new(HookBus::new());
568 let recorded = crate::runtime::emit_after_tool_call(
569 &bus,
570 "bash",
571 None,
572 serde_json::json!({}),
573 "RAW".to_string(),
574 30_000,
575 )
576 .await;
577
578 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
579 }
580
581 #[tokio::test]
585 async fn replace_stops_chain_for_after_tool_call() {
586 let bus = HookBus::new();
587 let first = TestHandler::new(
588 "first",
589 HookResult::Replace {
590 output: "FIRST".into(),
591 },
592 );
593 let second = TestHandler::new(
594 "second",
595 HookResult::Replace {
596 output: "SECOND".into(),
597 },
598 );
599 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
600
601 bus.subscribe(
602 HookKind::AfterToolCall,
603 first.clone(),
604 None,
605 None,
606 perms.clone(),
607 )
608 .await
609 .unwrap();
610 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
611 .await
612 .unwrap();
613
614 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
615 let result = bus.emit(&event).await;
616
617 assert_eq!(
618 result,
619 HookResult::Replace {
620 output: "FIRST".into()
621 }
622 );
623 assert_eq!(first.calls(), 1);
624 assert_eq!(second.calls(), 0); }
626
627 #[tokio::test]
631 async fn replace_without_transform_permission_is_ignored() {
632 let bus = std::sync::Arc::new(HookBus::new());
633 let handler = TestHandler::new(
634 "observer-only",
635 HookResult::Replace {
636 output: "SNEAKY".into(),
637 },
638 );
639 bus.subscribe(
641 HookKind::AfterToolCall,
642 handler.clone(),
643 None,
644 None,
645 perms_with(&[Permission::ToolsIntercept]),
646 )
647 .await
648 .unwrap();
649
650 let recorded = crate::runtime::emit_after_tool_call(
651 &bus,
652 "bash",
653 None,
654 serde_json::json!({}),
655 "ORIGINAL".to_string(),
656 30_000,
657 )
658 .await;
659
660 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
661 assert_eq!(
662 recorded, "ORIGINAL",
663 "Replace without tools.transform_output must be ignored — original preserved"
664 );
665 }
666
667 #[test]
668 fn trace_env_value_parser_accepts_common_truthy_values() {
669 for value in ["1", "true", "TRUE", "yes", "on"] {
670 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
671 assert!(
672 extensions_trace_enabled(),
673 "{value} should enable trace mode"
674 );
675 }
676
677 for value in ["", "0", "false", "off", "no"] {
678 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
679 assert!(
680 !extensions_trace_enabled(),
681 "{value:?} should not enable trace mode"
682 );
683 }
684 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
685 }
686
687 #[tokio::test]
688 async fn matcher_skips_handler_when_input_does_not_contain_value() {
689 let bus = HookBus::new();
690 let handler = TestHandler::new(
691 "matcher",
692 HookResult::Block {
693 reason: "matched".into(),
694 },
695 );
696 let mut perms = PermissionSet::new();
697 perms.grant(Permission::ToolsIntercept);
698 bus.subscribe(
699 HookKind::BeforeToolCall,
700 handler.clone(),
701 None,
702 Some(HookMatcher {
703 input_contains: Some("danger".to_string()),
704 input_equals: None,
705 }),
706 perms,
707 )
708 .await
709 .unwrap();
710
711 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
712 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
713
714 let danger =
715 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
716 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
717 }
718
719 #[test]
720 fn hook_result_action_names_are_stable_for_trace_logs() {
721 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
722 assert_eq!(
723 hook_result_action(&HookResult::Block {
724 reason: "stop".into(),
725 }),
726 "block"
727 );
728 assert_eq!(
729 hook_result_action(&HookResult::Inject {
730 content: "context".into(),
731 }),
732 "inject"
733 );
734 assert_eq!(
735 hook_result_action(&HookResult::Confirm {
736 message: "Proceed?".into(),
737 }),
738 "confirm"
739 );
740 assert_eq!(
741 hook_result_action(&HookResult::Modify {
742 input: serde_json::json!({"command": "echo safe"}),
743 }),
744 "modify"
745 );
746 }
747
748 #[tokio::test]
749 async fn empty_bus_returns_continue() {
750 let bus = HookBus::new();
751 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
752 let result = bus.emit(&event).await;
753 assert!(matches!(result, HookResult::Continue));
754 }
755
756 #[tokio::test]
757 async fn handler_receives_events() {
758 let bus = HookBus::new();
759 let handler = TestHandler::new("test-ext", HookResult::Continue);
760 let perms = perms_with(&[Permission::ToolsIntercept]);
761
762 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
763 .await
764 .unwrap();
765
766 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
767 bus.emit(&event).await;
768
769 assert_eq!(handler.calls(), 1);
770 }
771
772 #[tokio::test]
773 async fn confirm_stops_chain_for_before_tool_call() {
774 let bus = HookBus::new();
775 let confirmer = TestHandler::new(
776 "confirmer",
777 HookResult::Confirm {
778 message: "Run this command?".into(),
779 },
780 );
781 let after = TestHandler::new("after", HookResult::Continue);
782 let perms = perms_with(&[Permission::ToolsIntercept]);
783
784 bus.subscribe(
785 HookKind::BeforeToolCall,
786 confirmer.clone(),
787 None,
788 None,
789 perms.clone(),
790 )
791 .await
792 .unwrap();
793 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
794 .await
795 .unwrap();
796
797 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
798 let result = bus.emit(&event).await;
799
800 assert!(matches!(result, HookResult::Confirm { .. }));
801 assert_eq!(confirmer.calls(), 1);
802 assert_eq!(after.calls(), 0);
803 }
804
805 #[tokio::test]
806 async fn confirm_is_ignored_for_non_tool_hooks() {
807 let bus = HookBus::new();
808 let confirmer = TestHandler::new(
809 "confirmer",
810 HookResult::Confirm {
811 message: "Not allowed here".into(),
812 },
813 );
814 let perms = perms_with(&[Permission::LlmContent]);
815
816 bus.subscribe(
817 HookKind::BeforeMessage,
818 confirmer.clone(),
819 None,
820 None,
821 perms,
822 )
823 .await
824 .unwrap();
825
826 let event = HookEvent::before_message("hello");
827 let result = bus.emit(&event).await;
828
829 assert!(matches!(result, HookResult::Continue));
830 assert_eq!(confirmer.calls(), 1);
831 }
832
833 #[tokio::test]
834 async fn block_stops_chain() {
835 let bus = HookBus::new();
836 let blocker = TestHandler::new(
837 "blocker",
838 HookResult::Block {
839 reason: "dangerous".into(),
840 },
841 );
842 let after = TestHandler::new("after", HookResult::Continue);
843 let perms = perms_with(&[Permission::ToolsIntercept]);
844
845 bus.subscribe(
846 HookKind::BeforeToolCall,
847 blocker.clone(),
848 None,
849 None,
850 perms.clone(),
851 )
852 .await
853 .unwrap();
854 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
855 .await
856 .unwrap();
857
858 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
859 let result = bus.emit(&event).await;
860
861 assert!(matches!(result, HookResult::Block { .. }));
862 assert_eq!(blocker.calls(), 1);
863 assert_eq!(after.calls(), 0); }
865
866 #[tokio::test]
867 async fn modify_stops_chain() {
868 let bus = HookBus::new();
869 let modifier = TestHandler::new(
870 "modifier",
871 HookResult::Modify {
872 input: serde_json::json!({"command": "echo safe"}),
873 },
874 );
875 let after = TestHandler::new(
876 "after",
877 HookResult::Block {
878 reason: "should not run".into(),
879 },
880 );
881 let perms = perms_with(&[Permission::ToolsIntercept]);
882
883 bus.subscribe(
884 HookKind::BeforeToolCall,
885 modifier.clone(),
886 None,
887 None,
888 perms.clone(),
889 )
890 .await
891 .unwrap();
892 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
893 .await
894 .unwrap();
895
896 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
897 let result = bus.emit(&event).await;
898
899 assert!(
900 matches!(result, HookResult::Modify { input } if input == serde_json::json!({"command": "echo safe"}))
901 );
902 assert_eq!(modifier.calls(), 1);
903 assert_eq!(after.calls(), 0); }
905
906 #[tokio::test]
907 async fn tool_filter_only_matches_specified_tool() {
908 let bus = HookBus::new();
909 let handler = TestHandler::new("bash-only", HookResult::Continue);
910 let perms = perms_with(&[Permission::ToolsIntercept]);
911
912 bus.subscribe(
913 HookKind::AfterToolCall,
914 handler.clone(),
915 Some("bash".into()),
916 None,
917 perms,
918 )
919 .await
920 .unwrap();
921
922 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
924 bus.emit(&event).await;
925 assert_eq!(handler.calls(), 0);
926
927 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
929 bus.emit(&event).await;
930 assert_eq!(handler.calls(), 1);
931 }
932
933 #[tokio::test]
934 async fn permission_denied_rejects_subscribe() {
935 let bus = HookBus::new();
936 let handler = TestHandler::new("no-perms", HookResult::Continue);
937 let perms = PermissionSet::new(); let result = bus
940 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
941 .await;
942
943 assert!(result.is_err());
944 assert!(result.unwrap_err().contains("lacks permission"));
945 }
946
947 #[tokio::test]
948 async fn unsubscribe_removes_handlers() {
949 let bus = HookBus::new();
950 let handler = TestHandler::new("removable", HookResult::Continue);
951 let perms = perms_with(&[Permission::ToolsIntercept]);
952
953 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
954 .await
955 .unwrap();
956 assert_eq!(bus.handler_count().await, 1);
957
958 bus.unsubscribe_all("removable").await;
959 assert_eq!(bus.handler_count().await, 0);
960 }
961
962 #[tokio::test]
963 async fn subscriptions_for_lists_only_matching_extension() {
964 let bus = HookBus::new();
965 let alpha = TestHandler::new("alpha", HookResult::Continue);
966 let beta = TestHandler::new("beta", HookResult::Continue);
967 let perms = perms_with(&[Permission::ToolsIntercept]);
968
969 bus.subscribe(
970 HookKind::BeforeToolCall,
971 alpha.clone(),
972 Some("bash".into()),
973 None,
974 perms.clone(),
975 )
976 .await
977 .unwrap();
978 bus.subscribe(
979 HookKind::AfterToolCall,
980 alpha.clone(),
981 None,
982 None,
983 perms.clone(),
984 )
985 .await
986 .unwrap();
987 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
988 .await
989 .unwrap();
990
991 let alpha_subs = bus.subscriptions_for("alpha").await;
992 assert_eq!(alpha_subs.len(), 2);
993 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
995 assert_eq!(alpha_subs[0].1, None);
996 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
997 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
998
999 let beta_subs = bus.subscriptions_for("beta").await;
1000 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1001
1002 let none_subs = bus.subscriptions_for("ghost").await;
1003 assert!(none_subs.is_empty());
1004 }
1005
1006 #[tokio::test]
1007 async fn is_empty_reflects_state() {
1008 let bus = HookBus::new();
1009 assert!(bus.is_empty().await);
1010
1011 let handler = TestHandler::new("ext", HookResult::Continue);
1012 let perms = perms_with(&[Permission::ToolsIntercept]);
1013 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1014 .await
1015 .unwrap();
1016 assert!(!bus.is_empty().await);
1017 }
1018
1019 #[derive(Debug)]
1024 struct RecordingHandler {
1025 id: String,
1026 recorded_len: Arc<AtomicUsize>,
1027 result: HookResult,
1028 }
1029 impl RecordingHandler {
1030 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1031 let recorded_len = Arc::new(AtomicUsize::new(0));
1032 let h = Arc::new(Self {
1033 id: id.to_string(),
1034 recorded_len: recorded_len.clone(),
1035 result,
1036 });
1037 (h, recorded_len)
1038 }
1039 }
1040 #[async_trait]
1041 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1042 fn id(&self) -> &str {
1043 &self.id
1044 }
1045 async fn handle(&self, event: &HookEvent) -> HookResult {
1046 if let Some(out) = event.tool_output.as_ref() {
1047 self.recorded_len.store(out.len(), Ordering::Relaxed);
1048 }
1049 self.result.clone()
1050 }
1051 async fn shutdown(&self) {}
1052 }
1053
1054 #[tokio::test]
1056 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1057 use crate::tools::Tool;
1058 let bus = std::sync::Arc::new(HookBus::new());
1059 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1060 bus.subscribe(
1061 HookKind::AfterToolCall,
1062 handler,
1063 None,
1064 None,
1065 perms_with(&[Permission::ToolsIntercept]),
1066 )
1067 .await
1068 .unwrap();
1069
1070 let ctx = crate::tools::ToolContext {
1071 channels: crate::tools::ToolChannels {
1072 tx_delta: None,
1073 tx_events: None,
1074 },
1075 capabilities: crate::tools::ToolCapabilities {
1076 watcher_exit_path: None,
1077 tool_register_tx: None,
1078 session_manager: None,
1079 subagent_registry: None,
1080 event_queue: None,
1081 delegation_parent: None,
1082 secret_prompt: None,
1083 orchestration: None,
1084 tool_activation: None,
1085 mcp_leases: None,
1086 extension_leases: None,
1087 memory_context: None,
1088 },
1089 limits: crate::tools::ToolLimits {
1090 max_tool_output: 30_000,
1091 max_tool_buffer: 256 * 1024,
1092 bash_timeout: 30,
1093 bash_max_timeout: 300,
1094 subagent_timeout: 300,
1095 },
1096 };
1097
1098 let bash = crate::tools::BashTool;
1099 let output = bash
1100 .execute(
1101 serde_json::json!({"command": "yes hello | head -c 150000"}),
1102 ctx,
1103 )
1104 .await
1105 .expect("bash should succeed");
1106
1107 let _ = crate::runtime::emit_after_tool_call(
1108 &bus,
1109 "bash",
1110 None,
1111 serde_json::json!({"command": "yes hello | head -c 150000"}),
1112 output,
1113 30_000,
1114 )
1115 .await;
1116
1117 let len = recorded_len.load(Ordering::Relaxed);
1118 assert!(
1119 len > 100_000,
1120 "after_tool_call handler was starved: received {len} bytes — \
1121 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1122 );
1123 }
1124
1125 #[tokio::test]
1129 async fn final_output_truncated_to_max_tool_output_after_hook() {
1130 let bus = std::sync::Arc::new(HookBus::new());
1131 let big: String = "x".repeat(100_000);
1132 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1133 bus.subscribe(
1134 HookKind::AfterToolCall,
1135 handler,
1136 None,
1137 None,
1138 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1139 )
1140 .await
1141 .unwrap();
1142
1143 let result = crate::runtime::emit_after_tool_call(
1144 &bus,
1145 "bash",
1146 None,
1147 serde_json::json!({}),
1148 "tiny original".to_string(),
1149 30_000,
1150 )
1151 .await;
1152
1153 assert!(
1154 result.len() <= 30_000 + 200,
1155 "post-hook output should be capped at max_tool_output (got {} bytes)",
1156 result.len()
1157 );
1158 }
1159
1160 #[tokio::test]
1163 async fn no_transform_extension_preserves_legacy_truncation() {
1164 let bus = std::sync::Arc::new(HookBus::new());
1165 let huge: String = "y".repeat(80_000);
1166
1167 let result = crate::runtime::emit_after_tool_call(
1168 &bus,
1169 "bash",
1170 None,
1171 serde_json::json!({}),
1172 huge.clone(),
1173 30_000,
1174 )
1175 .await;
1176
1177 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1178 assert_eq!(
1179 result, legacy,
1180 "no-extension path must be byte-identical to legacy truncation"
1181 );
1182 }
1183
1184 #[tokio::test]
1187 async fn emit_after_tool_call_truncates_char_boundary_safe() {
1188 let bus = std::sync::Arc::new(HookBus::new());
1190 let multibyte: String = "héllo🌟".repeat(5_000);
1191 assert!(multibyte.len() > 30_000);
1192
1193 let result = crate::runtime::emit_after_tool_call(
1194 &bus,
1195 "bash",
1196 None,
1197 serde_json::json!({}),
1198 multibyte,
1199 30_000,
1200 )
1201 .await;
1202 assert!(result.is_char_boundary(result.len()));
1203
1204 let bus2 = std::sync::Arc::new(HookBus::new());
1206 let big_multi = "✨".repeat(20_000);
1207 assert!(big_multi.len() > 30_000);
1208 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1209 bus2.subscribe(
1210 HookKind::AfterToolCall,
1211 handler,
1212 None,
1213 None,
1214 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1215 )
1216 .await
1217 .unwrap();
1218
1219 let result2 = crate::runtime::emit_after_tool_call(
1220 &bus2,
1221 "bash",
1222 None,
1223 serde_json::json!({}),
1224 "orig".to_string(),
1225 30_000,
1226 )
1227 .await;
1228 assert!(result2.is_char_boundary(result2.len()));
1229 }
1230}