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<HashMap<String, String>>,
75}
76
77impl HookBus {
78 pub fn new() -> Self {
80 Self {
81 handlers: RwLock::new(HashMap::new()),
82 session_injection: RwLock::new(HashMap::new()),
83 }
84 }
85
86 pub async fn set_session_injection_for(&self, session_id: &str, content: String) {
93 self.session_injection
94 .write()
95 .await
96 .insert(session_id.to_string(), content);
97 }
98
99 pub async fn session_injection_for(&self, session_id: &str) -> Option<String> {
102 self.session_injection.read().await.get(session_id).cloned()
103 }
104
105 pub async fn clear_session_injection(&self, session_id: &str) {
107 self.session_injection.write().await.remove(session_id);
108 }
109
110 #[deprecated(note = "use set_session_injection_for")]
112 pub async fn set_session_injection(&self, content: String) {
113 self.set_session_injection_for("", content).await;
114 }
115
116 #[deprecated(note = "use session_injection_for")]
118 pub async fn session_injection(&self) -> Option<String> {
119 self.session_injection_for("").await
120 }
121
122 pub async fn subscribe(
127 &self,
128 kind: HookKind,
129 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
130 tool_filter: Option<String>,
131 matcher: Option<HookMatcher>,
132 permissions: PermissionSet,
133 ) -> Result<(), String> {
134 if !permissions.allows_hook(kind) {
136 return Err(format!(
137 "Extension '{}' lacks permission '{}' required for hook '{}'",
138 handler.id(),
139 kind.required_permission().as_str(),
140 kind.as_str(),
141 ));
142 }
143
144 let reg = HandlerRegistration {
145 handler,
146 tool_filter,
147 matcher,
148 permissions,
149 };
150
151 let mut handlers = self.handlers.write().await;
152 handlers.entry(kind).or_default().push(reg);
153 Ok(())
154 }
155
156 pub async fn emit(&self, event: &HookEvent) -> HookResult {
164 let registrations = {
169 let handlers = self.handlers.read().await;
170 match handlers.get(&event.kind) {
171 Some(regs) if !regs.is_empty() => regs.clone(),
172 _ => return HookResult::Continue, }
174 }; let mut injections: Vec<String> = Vec::new();
178
179 for reg in ®istrations {
180 if let Some(ref filter) = reg.tool_filter {
184 let matches = match (&event.tool_name, &event.tool_runtime_name) {
185 (Some(api), Some(runtime)) => filter == api || filter == runtime,
186 (Some(api), None) => filter == api,
187 (None, Some(runtime)) => filter == runtime,
188 (None, None) => false,
189 };
190 if !matches {
191 continue;
192 }
193 }
194
195 if let Some(ref matcher) = reg.matcher {
196 if !matcher.matches(event) {
197 continue;
198 }
199 }
200
201 let handler = reg.handler.clone();
203 let event_clone = event.clone();
204 let trace_enabled = extensions_trace_enabled();
205 let started_at = trace_enabled.then(Instant::now);
206 let result = tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await;
207
208 if trace_enabled {
209 let health = reg.handler.health().await;
210 let health = health.as_str();
211 let restart_count = reg.handler.restart_count().await;
212 let duration_ms = started_at
213 .map(|start| start.elapsed().as_millis() as u64)
214 .unwrap_or(0);
215 match &result {
216 Ok(hook_result) => {
217 let action = hook_result_action(hook_result);
218 tracing::info!(
219 extension_trace = true,
220 hook = %event.kind.as_str(),
221 extension = %reg.handler.id(),
222 action = action,
223 duration_ms = duration_ms,
224 health = health,
225 restart_count = restart_count,
226 "Extension hook trace"
227 );
228 }
229 Err(_) => {
230 tracing::warn!(
231 extension_trace = true,
232 hook = %event.kind.as_str(),
233 extension = %reg.handler.id(),
234 action = "timeout",
235 duration_ms = duration_ms,
236 timeout_secs = HANDLER_TIMEOUT.as_secs(),
237 health = health,
238 restart_count = restart_count,
239 "Extension hook trace"
240 );
241 }
242 }
243 }
244
245 match result {
246 Ok(result) if !event.kind.allows_result(&result) => {
247 tracing::warn!(
248 hook = %event.kind.as_str(),
249 extension = %reg.handler.id(),
250 action = hook_result_action(&result),
251 "Extension returned action not allowed for hook — ignoring"
252 );
253 continue;
254 }
255 Ok(HookResult::Block { reason }) => {
256 tracing::info!(
257 hook = %event.kind.as_str(),
258 extension = %reg.handler.id(),
259 reason = %reason,
260 "Hook blocked by extension"
261 );
262 return HookResult::Block { reason };
263 }
264 Ok(HookResult::Continue) => {}
265 Ok(HookResult::Inject { content }) => {
266 tracing::debug!(
267 hook = %event.kind.as_str(),
268 extension = %reg.handler.id(),
269 len = content.len(),
270 "Extension injected context"
271 );
272 injections.push(content);
274 }
275 Ok(HookResult::Modify { input }) => {
276 tracing::info!(
277 hook = %event.kind.as_str(),
278 extension = %reg.handler.id(),
279 "Hook modified tool input by extension"
280 );
281 return HookResult::Modify { input };
282 }
283 Ok(HookResult::Replace { output }) => {
284 if !reg.permissions.has(Permission::ToolsTransformOutput) {
290 tracing::warn!(
291 hook = %event.kind.as_str(),
292 extension = %reg.handler.id(),
293 "Extension returned Replace without tools.transform_output permission — ignoring transform"
294 );
295 continue;
296 }
297 let observed_len = event.tool_output.as_ref().map_or(0, String::len);
298 tracing::info!(
299 hook = %event.kind.as_str(),
300 extension = %reg.handler.id(),
301 observed_len,
302 replacement_len = output.len(),
303 "Hook replaced tool output by extension"
304 );
305 let _ = crate::extensions::audit::record_tool_output_replace(
309 reg.handler.id(),
310 event.kind.as_str(),
311 event.tool_name.clone(),
312 observed_len,
313 output.len(),
314 );
315 return HookResult::Replace { output };
317 }
318 Ok(HookResult::Confirm { message }) => {
319 tracing::info!(
320 hook = %event.kind.as_str(),
321 extension = %reg.handler.id(),
322 "Hook requested confirmation by extension"
323 );
324 return HookResult::Confirm { message };
325 }
326 Err(_timeout) => {
327 tracing::warn!(
328 hook = %event.kind.as_str(),
329 extension = %reg.handler.id(),
330 timeout_secs = HANDLER_TIMEOUT.as_secs(),
331 "Hook handler timed out — skipping"
332 );
333 }
335 }
336 }
337
338 if !injections.is_empty() {
340 HookResult::Inject {
341 content: injections.join("\n\n"),
342 }
343 } else {
344 HookResult::Continue
345 }
346 }
347
348 pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
372 assert!(
375 !matches!(
376 event.kind,
377 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
378 ),
379 "emit_concurrent must not be used for transform-capable hooks \
380 ({:?}); their Block/Modify/Replace/Inject results need the \
381 deterministic first-wins ordering of the sequential emit()",
382 event.kind,
383 );
384 let registrations = {
386 let handlers = self.handlers.read().await;
387 match handlers.get(&event.kind) {
388 Some(regs) if !regs.is_empty() => regs.clone(),
389 _ => return HookResult::Continue, }
391 };
392
393 let futures: Vec<_> =
395 registrations
396 .iter()
397 .filter(|reg| {
398 if let Some(ref filter) = reg.tool_filter {
400 match (&event.tool_name, &event.tool_runtime_name) {
401 (Some(api), Some(runtime)) => filter == api || filter == runtime,
402 (Some(api), None) => filter == api,
403 (None, Some(runtime)) => filter == runtime,
404 (None, None) => false,
405 }
406 } else {
407 true
408 }
409 })
410 .filter(|reg| reg.matcher.as_ref().map_or(true, |m| m.matches(event)))
411 .map(|reg| {
412 let handler = reg.handler.clone();
413 let event_clone = event.clone();
414 async move {
415 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await
416 }
417 })
418 .collect();
419
420 let results = join_all(futures).await;
421
422 let mut injections: Vec<String> = Vec::new();
423 for result in results {
424 match result {
425 Ok(HookResult::Continue) => {}
426 Ok(HookResult::Block { reason }) => {
427 return HookResult::Block { reason };
428 }
429 Ok(HookResult::Inject { content }) => {
430 injections.push(content);
431 }
432 Ok(HookResult::Modify { input }) => {
433 return HookResult::Modify { input };
434 }
435 Ok(HookResult::Replace { output }) => {
436 return HookResult::Replace { output };
437 }
438 Ok(HookResult::Confirm { message }) => {
439 return HookResult::Confirm { message };
440 }
441 Err(_timeout) => {
442 tracing::warn!(
443 hook = %event.kind.as_str(),
444 timeout_secs = HANDLER_TIMEOUT.as_secs(),
445 "Hook handler timed out in concurrent emit — skipping"
446 );
447 }
448 }
449 }
450
451 if !injections.is_empty() {
452 HookResult::Inject {
453 content: injections.join("\n\n"),
454 }
455 } else {
456 HookResult::Continue
457 }
458 }
459
460 pub async fn unsubscribe_all(&self, extension_id: &str) {
462 let mut handlers = self.handlers.write().await;
463 for regs in handlers.values_mut() {
464 regs.retain(|r| r.handler.id() != extension_id);
465 }
466 }
467
468 pub async fn handler_count(&self) -> usize {
470 let handlers = self.handlers.read().await;
471 handlers.values().map(|v| v.len()).sum()
472 }
473
474 pub async fn is_empty(&self) -> bool {
476 let handlers = self.handlers.read().await;
477 handlers.values().all(|v| v.is_empty())
478 }
479
480 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
483 let handlers = self.handlers.read().await;
484 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
485 for (kind, regs) in handlers.iter() {
486 for reg in regs {
487 if reg.handler.id() == extension_id {
488 out.push((*kind, reg.tool_filter.clone()));
489 }
490 }
491 }
492 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
493 out
494 }
495}
496
497impl Default for HookBus {
498 fn default() -> Self {
499 Self::new()
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::extensions::hooks::events::HookEvent;
507 use crate::extensions::permissions::Permission;
508 use async_trait::async_trait;
509 use std::sync::atomic::{AtomicUsize, Ordering};
510
511 struct TestHandler {
513 id: String,
514 call_count: AtomicUsize,
515 result: HookResult,
516 }
517
518 impl TestHandler {
519 fn new(id: &str, result: HookResult) -> Arc<Self> {
520 Arc::new(Self {
521 id: id.to_string(),
522 call_count: AtomicUsize::new(0),
523 result,
524 })
525 }
526
527 fn calls(&self) -> usize {
528 self.call_count.load(Ordering::Relaxed)
529 }
530 }
531
532 #[async_trait]
533 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
534 fn id(&self) -> &str {
535 &self.id
536 }
537
538 async fn handle(&self, _event: &HookEvent) -> HookResult {
539 self.call_count.fetch_add(1, Ordering::Relaxed);
540 self.result.clone()
541 }
542
543 async fn shutdown(&self) {}
544 }
545
546 fn perms_with(perms: &[Permission]) -> PermissionSet {
547 let mut set = PermissionSet::new();
548 for p in perms {
549 set.grant(*p);
550 }
551 set
552 }
553
554 #[tokio::test]
558 #[serial_test::serial(synaps_base_dir)]
559 async fn after_tool_call_replace_substitutes_recorded_output() {
560 let base = crate::test_env::BaseDirGuard::new();
562 let bus = std::sync::Arc::new(HookBus::new());
563 let handler = TestHandler::new(
564 "compressor",
565 HookResult::Replace {
566 output: "COMPRESSED".into(),
567 },
568 );
569 bus.subscribe(
570 HookKind::AfterToolCall,
571 handler,
572 None,
573 None,
574 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
575 )
576 .await
577 .unwrap();
578
579 let recorded = crate::runtime::emit_after_tool_call(
580 &bus,
581 "bash",
582 None,
583 serde_json::json!({"command": "cat huge.log"}),
584 "RAW 10k lines".to_string(),
585 30_000,
586 None,
587 )
588 .await;
589
590 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
591 let audit = std::fs::read_to_string(
592 crate::extensions::audit::intercept_file_path_for(base.path()),
593 )
594 .expect("intercept audit must land in the isolated base dir");
595 assert!(audit.contains("\"plugin_id\":\"compressor\""));
596 }
597
598 #[tokio::test]
600 async fn after_tool_call_without_replace_keeps_original_output() {
601 let bus = std::sync::Arc::new(HookBus::new());
602 let recorded = crate::runtime::emit_after_tool_call(
603 &bus,
604 "bash",
605 None,
606 serde_json::json!({}),
607 "RAW".to_string(),
608 30_000,
609 None,
610 )
611 .await;
612
613 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
614 }
615
616 #[tokio::test]
620 #[serial_test::serial(synaps_base_dir)]
621 async fn replace_stops_chain_for_after_tool_call() {
622 let _base = crate::test_env::BaseDirGuard::new();
624 let bus = HookBus::new();
625 let first = TestHandler::new(
626 "first",
627 HookResult::Replace {
628 output: "FIRST".into(),
629 },
630 );
631 let second = TestHandler::new(
632 "second",
633 HookResult::Replace {
634 output: "SECOND".into(),
635 },
636 );
637 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
638
639 bus.subscribe(
640 HookKind::AfterToolCall,
641 first.clone(),
642 None,
643 None,
644 perms.clone(),
645 )
646 .await
647 .unwrap();
648 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
649 .await
650 .unwrap();
651
652 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
653 let result = bus.emit(&event).await;
654
655 assert_eq!(
656 result,
657 HookResult::Replace {
658 output: "FIRST".into()
659 }
660 );
661 assert_eq!(first.calls(), 1);
662 assert_eq!(second.calls(), 0); }
664
665 #[tokio::test]
669 async fn replace_without_transform_permission_is_ignored() {
670 let bus = std::sync::Arc::new(HookBus::new());
671 let handler = TestHandler::new(
672 "observer-only",
673 HookResult::Replace {
674 output: "SNEAKY".into(),
675 },
676 );
677 bus.subscribe(
679 HookKind::AfterToolCall,
680 handler.clone(),
681 None,
682 None,
683 perms_with(&[Permission::ToolsIntercept]),
684 )
685 .await
686 .unwrap();
687
688 let recorded = crate::runtime::emit_after_tool_call(
689 &bus,
690 "bash",
691 None,
692 serde_json::json!({}),
693 "ORIGINAL".to_string(),
694 30_000,
695 None,
696 )
697 .await;
698
699 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
700 assert_eq!(
701 recorded, "ORIGINAL",
702 "Replace without tools.transform_output must be ignored — original preserved"
703 );
704 }
705
706 #[test]
707 fn trace_env_value_parser_accepts_common_truthy_values() {
708 for value in ["1", "true", "TRUE", "yes", "on"] {
709 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
710 assert!(
711 extensions_trace_enabled(),
712 "{value} should enable trace mode"
713 );
714 }
715
716 for value in ["", "0", "false", "off", "no"] {
717 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
718 assert!(
719 !extensions_trace_enabled(),
720 "{value:?} should not enable trace mode"
721 );
722 }
723 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
724 }
725
726 #[tokio::test]
727 async fn matcher_skips_handler_when_input_does_not_contain_value() {
728 let bus = HookBus::new();
729 let handler = TestHandler::new(
730 "matcher",
731 HookResult::Block {
732 reason: "matched".into(),
733 },
734 );
735 let mut perms = PermissionSet::new();
736 perms.grant(Permission::ToolsIntercept);
737 bus.subscribe(
738 HookKind::BeforeToolCall,
739 handler.clone(),
740 None,
741 Some(HookMatcher {
742 input_contains: Some("danger".to_string()),
743 input_equals: None,
744 }),
745 perms,
746 )
747 .await
748 .unwrap();
749
750 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
751 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
752
753 let danger =
754 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
755 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
756 }
757
758 #[test]
759 fn hook_result_action_names_are_stable_for_trace_logs() {
760 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
761 assert_eq!(
762 hook_result_action(&HookResult::Block {
763 reason: "stop".into(),
764 }),
765 "block"
766 );
767 assert_eq!(
768 hook_result_action(&HookResult::Inject {
769 content: "context".into(),
770 }),
771 "inject"
772 );
773 assert_eq!(
774 hook_result_action(&HookResult::Confirm {
775 message: "Proceed?".into(),
776 }),
777 "confirm"
778 );
779 assert_eq!(
780 hook_result_action(&HookResult::Modify {
781 input: serde_json::json!({"command": "echo safe"}),
782 }),
783 "modify"
784 );
785 }
786
787 #[tokio::test]
788 async fn empty_bus_returns_continue() {
789 let bus = HookBus::new();
790 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
791 let result = bus.emit(&event).await;
792 assert!(matches!(result, HookResult::Continue));
793 }
794
795 #[tokio::test]
796 async fn handler_receives_events() {
797 let bus = HookBus::new();
798 let handler = TestHandler::new("test-ext", HookResult::Continue);
799 let perms = perms_with(&[Permission::ToolsIntercept]);
800
801 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
802 .await
803 .unwrap();
804
805 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
806 bus.emit(&event).await;
807
808 assert_eq!(handler.calls(), 1);
809 }
810
811 #[tokio::test]
812 async fn confirm_stops_chain_for_before_tool_call() {
813 let bus = HookBus::new();
814 let confirmer = TestHandler::new(
815 "confirmer",
816 HookResult::Confirm {
817 message: "Run this command?".into(),
818 },
819 );
820 let after = TestHandler::new("after", HookResult::Continue);
821 let perms = perms_with(&[Permission::ToolsIntercept]);
822
823 bus.subscribe(
824 HookKind::BeforeToolCall,
825 confirmer.clone(),
826 None,
827 None,
828 perms.clone(),
829 )
830 .await
831 .unwrap();
832 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
833 .await
834 .unwrap();
835
836 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
837 let result = bus.emit(&event).await;
838
839 assert!(matches!(result, HookResult::Confirm { .. }));
840 assert_eq!(confirmer.calls(), 1);
841 assert_eq!(after.calls(), 0);
842 }
843
844 #[tokio::test]
845 async fn confirm_is_ignored_for_non_tool_hooks() {
846 let bus = HookBus::new();
847 let confirmer = TestHandler::new(
848 "confirmer",
849 HookResult::Confirm {
850 message: "Not allowed here".into(),
851 },
852 );
853 let perms = perms_with(&[Permission::LlmContent]);
854
855 bus.subscribe(
856 HookKind::BeforeMessage,
857 confirmer.clone(),
858 None,
859 None,
860 perms,
861 )
862 .await
863 .unwrap();
864
865 let event = HookEvent::before_message("hello");
866 let result = bus.emit(&event).await;
867
868 assert!(matches!(result, HookResult::Continue));
869 assert_eq!(confirmer.calls(), 1);
870 }
871
872 #[tokio::test]
873 async fn block_stops_chain() {
874 let bus = HookBus::new();
875 let blocker = TestHandler::new(
876 "blocker",
877 HookResult::Block {
878 reason: "dangerous".into(),
879 },
880 );
881 let after = TestHandler::new("after", HookResult::Continue);
882 let perms = perms_with(&[Permission::ToolsIntercept]);
883
884 bus.subscribe(
885 HookKind::BeforeToolCall,
886 blocker.clone(),
887 None,
888 None,
889 perms.clone(),
890 )
891 .await
892 .unwrap();
893 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
894 .await
895 .unwrap();
896
897 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
898 let result = bus.emit(&event).await;
899
900 assert!(matches!(result, HookResult::Block { .. }));
901 assert_eq!(blocker.calls(), 1);
902 assert_eq!(after.calls(), 0); }
904
905 #[tokio::test]
906 async fn modify_stops_chain() {
907 let bus = HookBus::new();
908 let modifier = TestHandler::new(
909 "modifier",
910 HookResult::Modify {
911 input: serde_json::json!({"command": "echo safe"}),
912 },
913 );
914 let after = TestHandler::new(
915 "after",
916 HookResult::Block {
917 reason: "should not run".into(),
918 },
919 );
920 let perms = perms_with(&[Permission::ToolsIntercept]);
921
922 bus.subscribe(
923 HookKind::BeforeToolCall,
924 modifier.clone(),
925 None,
926 None,
927 perms.clone(),
928 )
929 .await
930 .unwrap();
931 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
932 .await
933 .unwrap();
934
935 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
936 let result = bus.emit(&event).await;
937
938 assert!(
939 matches!(result, HookResult::Modify { input } if input == serde_json::json!({"command": "echo safe"}))
940 );
941 assert_eq!(modifier.calls(), 1);
942 assert_eq!(after.calls(), 0); }
944
945 #[tokio::test]
946 async fn tool_filter_only_matches_specified_tool() {
947 let bus = HookBus::new();
948 let handler = TestHandler::new("bash-only", HookResult::Continue);
949 let perms = perms_with(&[Permission::ToolsIntercept]);
950
951 bus.subscribe(
952 HookKind::AfterToolCall,
953 handler.clone(),
954 Some("bash".into()),
955 None,
956 perms,
957 )
958 .await
959 .unwrap();
960
961 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
963 bus.emit(&event).await;
964 assert_eq!(handler.calls(), 0);
965
966 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
968 bus.emit(&event).await;
969 assert_eq!(handler.calls(), 1);
970 }
971
972 #[tokio::test]
973 async fn permission_denied_rejects_subscribe() {
974 let bus = HookBus::new();
975 let handler = TestHandler::new("no-perms", HookResult::Continue);
976 let perms = PermissionSet::new(); let result = bus
979 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
980 .await;
981
982 assert!(result.is_err());
983 assert!(result.unwrap_err().contains("lacks permission"));
984 }
985
986 #[tokio::test]
987 async fn unsubscribe_removes_handlers() {
988 let bus = HookBus::new();
989 let handler = TestHandler::new("removable", HookResult::Continue);
990 let perms = perms_with(&[Permission::ToolsIntercept]);
991
992 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
993 .await
994 .unwrap();
995 assert_eq!(bus.handler_count().await, 1);
996
997 bus.unsubscribe_all("removable").await;
998 assert_eq!(bus.handler_count().await, 0);
999 }
1000
1001 #[tokio::test]
1002 async fn subscriptions_for_lists_only_matching_extension() {
1003 let bus = HookBus::new();
1004 let alpha = TestHandler::new("alpha", HookResult::Continue);
1005 let beta = TestHandler::new("beta", HookResult::Continue);
1006 let perms = perms_with(&[Permission::ToolsIntercept]);
1007
1008 bus.subscribe(
1009 HookKind::BeforeToolCall,
1010 alpha.clone(),
1011 Some("bash".into()),
1012 None,
1013 perms.clone(),
1014 )
1015 .await
1016 .unwrap();
1017 bus.subscribe(
1018 HookKind::AfterToolCall,
1019 alpha.clone(),
1020 None,
1021 None,
1022 perms.clone(),
1023 )
1024 .await
1025 .unwrap();
1026 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1027 .await
1028 .unwrap();
1029
1030 let alpha_subs = bus.subscriptions_for("alpha").await;
1031 assert_eq!(alpha_subs.len(), 2);
1032 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1034 assert_eq!(alpha_subs[0].1, None);
1035 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1036 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1037
1038 let beta_subs = bus.subscriptions_for("beta").await;
1039 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1040
1041 let none_subs = bus.subscriptions_for("ghost").await;
1042 assert!(none_subs.is_empty());
1043 }
1044
1045 #[tokio::test]
1046 async fn is_empty_reflects_state() {
1047 let bus = HookBus::new();
1048 assert!(bus.is_empty().await);
1049
1050 let handler = TestHandler::new("ext", HookResult::Continue);
1051 let perms = perms_with(&[Permission::ToolsIntercept]);
1052 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1053 .await
1054 .unwrap();
1055 assert!(!bus.is_empty().await);
1056 }
1057
1058 #[derive(Debug)]
1063 struct RecordingHandler {
1064 id: String,
1065 recorded_len: Arc<AtomicUsize>,
1066 result: HookResult,
1067 }
1068 impl RecordingHandler {
1069 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1070 let recorded_len = Arc::new(AtomicUsize::new(0));
1071 let h = Arc::new(Self {
1072 id: id.to_string(),
1073 recorded_len: recorded_len.clone(),
1074 result,
1075 });
1076 (h, recorded_len)
1077 }
1078 }
1079 #[async_trait]
1080 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1081 fn id(&self) -> &str {
1082 &self.id
1083 }
1084 async fn handle(&self, event: &HookEvent) -> HookResult {
1085 if let Some(out) = event.tool_output.as_ref() {
1086 self.recorded_len.store(out.len(), Ordering::Relaxed);
1087 }
1088 self.result.clone()
1089 }
1090 async fn shutdown(&self) {}
1091 }
1092
1093 #[tokio::test]
1095 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1096 use crate::tools::Tool;
1097 let bus = std::sync::Arc::new(HookBus::new());
1098 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1099 bus.subscribe(
1100 HookKind::AfterToolCall,
1101 handler,
1102 None,
1103 None,
1104 perms_with(&[Permission::ToolsIntercept]),
1105 )
1106 .await
1107 .unwrap();
1108
1109 let ctx = crate::tools::ToolContext {
1110 channels: crate::tools::ToolChannels {
1111 tx_delta: None,
1112 tx_events: None,
1113 },
1114 capabilities: crate::tools::ToolCapabilities {
1115 launch_cancel: None,
1116 memory_backend: None,
1117 watcher_exit_path: None,
1118 tool_register_tx: None,
1119 session_manager: None,
1120 subagent_registry: None,
1121 event_queue: None,
1122 delegation_parent: None,
1123 codex_parent_plan: None,
1124 secret_prompt: None,
1125 orchestration: None,
1126 tool_activation: None,
1127 mcp_leases: None,
1128 extension_leases: None,
1129 memory_context: None,
1130 cwd: None,
1131 env: None,
1132 env_stripped: Vec::new(),
1133 env_warned: Default::default(),
1134 },
1135 limits: crate::tools::ToolLimits {
1136 max_tool_output: 30_000,
1137 max_tool_buffer: 256 * 1024,
1138 bash_timeout: 30,
1139 bash_max_timeout: 300,
1140 subagent_timeout: 300,
1141 },
1142 };
1143
1144 let bash = crate::tools::BashTool;
1145 let output = bash
1146 .execute(
1147 serde_json::json!({"command": "yes hello | head -c 150000"}),
1148 ctx,
1149 )
1150 .await
1151 .expect("bash should succeed");
1152
1153 let _ = crate::runtime::emit_after_tool_call(
1154 &bus,
1155 "bash",
1156 None,
1157 serde_json::json!({"command": "yes hello | head -c 150000"}),
1158 output,
1159 30_000,
1160 None,
1161 )
1162 .await;
1163
1164 let len = recorded_len.load(Ordering::Relaxed);
1165 assert!(
1166 len > 100_000,
1167 "after_tool_call handler was starved: received {len} bytes — \
1168 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1169 );
1170 }
1171
1172 #[tokio::test]
1176 #[serial_test::serial(synaps_base_dir)]
1177 async fn final_output_truncated_to_max_tool_output_after_hook() {
1178 let _base = crate::test_env::BaseDirGuard::new();
1180 let bus = std::sync::Arc::new(HookBus::new());
1181 let big: String = "x".repeat(100_000);
1182 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1183 bus.subscribe(
1184 HookKind::AfterToolCall,
1185 handler,
1186 None,
1187 None,
1188 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1189 )
1190 .await
1191 .unwrap();
1192
1193 let result = crate::runtime::emit_after_tool_call(
1194 &bus,
1195 "bash",
1196 None,
1197 serde_json::json!({}),
1198 "tiny original".to_string(),
1199 30_000,
1200 None,
1201 )
1202 .await;
1203
1204 assert!(
1205 result.len() <= 30_000 + 200,
1206 "post-hook output should be capped at max_tool_output (got {} bytes)",
1207 result.len()
1208 );
1209 }
1210
1211 #[tokio::test]
1214 async fn no_transform_extension_preserves_legacy_truncation() {
1215 let bus = std::sync::Arc::new(HookBus::new());
1216 let huge: String = "y".repeat(80_000);
1217
1218 let result = crate::runtime::emit_after_tool_call(
1219 &bus,
1220 "bash",
1221 None,
1222 serde_json::json!({}),
1223 huge.clone(),
1224 30_000,
1225 None,
1226 )
1227 .await;
1228
1229 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1230 assert_eq!(
1231 result, legacy,
1232 "no-extension path must be byte-identical to legacy truncation"
1233 );
1234 }
1235
1236 #[tokio::test]
1239 #[serial_test::serial(synaps_base_dir)]
1240 async fn emit_after_tool_call_truncates_char_boundary_safe() {
1241 let _base = crate::test_env::BaseDirGuard::new();
1243 let bus = std::sync::Arc::new(HookBus::new());
1245 let multibyte: String = "héllo🌟".repeat(5_000);
1246 assert!(multibyte.len() > 30_000);
1247
1248 let result = crate::runtime::emit_after_tool_call(
1249 &bus,
1250 "bash",
1251 None,
1252 serde_json::json!({}),
1253 multibyte,
1254 30_000,
1255 None,
1256 )
1257 .await;
1258 assert!(result.is_char_boundary(result.len()));
1259
1260 let bus2 = std::sync::Arc::new(HookBus::new());
1262 let big_multi = "✨".repeat(20_000);
1263 assert!(big_multi.len() > 30_000);
1264 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1265 bus2.subscribe(
1266 HookKind::AfterToolCall,
1267 handler,
1268 None,
1269 None,
1270 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1271 )
1272 .await
1273 .unwrap();
1274
1275 let result2 = crate::runtime::emit_after_tool_call(
1276 &bus2,
1277 "bash",
1278 None,
1279 serde_json::json!({}),
1280 "orig".to_string(),
1281 30_000,
1282 None,
1283 )
1284 .await;
1285 assert!(result2.is_char_boundary(result2.len()));
1286 }
1287
1288 #[tokio::test]
1289 async fn keyed_injection_isolated_per_session() {
1290 let bus = HookBus::new();
1291 bus.set_session_injection_for("s1", "one".into()).await;
1292 bus.set_session_injection_for("s2", "two".into()).await;
1293 assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
1294 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
1295 assert_eq!(bus.session_injection_for("s3").await, None);
1296 }
1297
1298 #[tokio::test]
1299 async fn clear_removes_only_that_session() {
1300 let bus = HookBus::new();
1301 bus.set_session_injection_for("s1", "one".into()).await;
1302 bus.set_session_injection_for("s2", "two".into()).await;
1303 bus.clear_session_injection("s1").await;
1304 assert_eq!(bus.session_injection_for("s1").await, None);
1305 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
1306 }
1307
1308 #[tokio::test]
1309 #[allow(deprecated)]
1310 async fn deprecated_shims_use_empty_key() {
1311 let bus = HookBus::new();
1312 bus.set_session_injection("legacy".into()).await;
1313 assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
1314 assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
1315 assert_eq!(bus.session_injection_for("s1").await, None);
1316 }
1317}