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 async fn after_tool_call_replace_substitutes_recorded_output() {
559 let bus = std::sync::Arc::new(HookBus::new());
560 let handler = TestHandler::new(
561 "compressor",
562 HookResult::Replace {
563 output: "COMPRESSED".into(),
564 },
565 );
566 bus.subscribe(
567 HookKind::AfterToolCall,
568 handler,
569 None,
570 None,
571 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
572 )
573 .await
574 .unwrap();
575
576 let recorded = crate::runtime::emit_after_tool_call(
577 &bus,
578 "bash",
579 None,
580 serde_json::json!({"command": "cat huge.log"}),
581 "RAW 10k lines".to_string(),
582 30_000,
583 None,
584 )
585 .await;
586
587 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
588 }
589
590 #[tokio::test]
592 async fn after_tool_call_without_replace_keeps_original_output() {
593 let bus = std::sync::Arc::new(HookBus::new());
594 let recorded = crate::runtime::emit_after_tool_call(
595 &bus,
596 "bash",
597 None,
598 serde_json::json!({}),
599 "RAW".to_string(),
600 30_000,
601 None,
602 )
603 .await;
604
605 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
606 }
607
608 #[tokio::test]
612 async fn replace_stops_chain_for_after_tool_call() {
613 let bus = HookBus::new();
614 let first = TestHandler::new(
615 "first",
616 HookResult::Replace {
617 output: "FIRST".into(),
618 },
619 );
620 let second = TestHandler::new(
621 "second",
622 HookResult::Replace {
623 output: "SECOND".into(),
624 },
625 );
626 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
627
628 bus.subscribe(
629 HookKind::AfterToolCall,
630 first.clone(),
631 None,
632 None,
633 perms.clone(),
634 )
635 .await
636 .unwrap();
637 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
638 .await
639 .unwrap();
640
641 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
642 let result = bus.emit(&event).await;
643
644 assert_eq!(
645 result,
646 HookResult::Replace {
647 output: "FIRST".into()
648 }
649 );
650 assert_eq!(first.calls(), 1);
651 assert_eq!(second.calls(), 0); }
653
654 #[tokio::test]
658 async fn replace_without_transform_permission_is_ignored() {
659 let bus = std::sync::Arc::new(HookBus::new());
660 let handler = TestHandler::new(
661 "observer-only",
662 HookResult::Replace {
663 output: "SNEAKY".into(),
664 },
665 );
666 bus.subscribe(
668 HookKind::AfterToolCall,
669 handler.clone(),
670 None,
671 None,
672 perms_with(&[Permission::ToolsIntercept]),
673 )
674 .await
675 .unwrap();
676
677 let recorded = crate::runtime::emit_after_tool_call(
678 &bus,
679 "bash",
680 None,
681 serde_json::json!({}),
682 "ORIGINAL".to_string(),
683 30_000,
684 None,
685 )
686 .await;
687
688 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
689 assert_eq!(
690 recorded, "ORIGINAL",
691 "Replace without tools.transform_output must be ignored — original preserved"
692 );
693 }
694
695 #[test]
696 fn trace_env_value_parser_accepts_common_truthy_values() {
697 for value in ["1", "true", "TRUE", "yes", "on"] {
698 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
699 assert!(
700 extensions_trace_enabled(),
701 "{value} should enable trace mode"
702 );
703 }
704
705 for value in ["", "0", "false", "off", "no"] {
706 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
707 assert!(
708 !extensions_trace_enabled(),
709 "{value:?} should not enable trace mode"
710 );
711 }
712 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
713 }
714
715 #[tokio::test]
716 async fn matcher_skips_handler_when_input_does_not_contain_value() {
717 let bus = HookBus::new();
718 let handler = TestHandler::new(
719 "matcher",
720 HookResult::Block {
721 reason: "matched".into(),
722 },
723 );
724 let mut perms = PermissionSet::new();
725 perms.grant(Permission::ToolsIntercept);
726 bus.subscribe(
727 HookKind::BeforeToolCall,
728 handler.clone(),
729 None,
730 Some(HookMatcher {
731 input_contains: Some("danger".to_string()),
732 input_equals: None,
733 }),
734 perms,
735 )
736 .await
737 .unwrap();
738
739 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
740 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
741
742 let danger =
743 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
744 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
745 }
746
747 #[test]
748 fn hook_result_action_names_are_stable_for_trace_logs() {
749 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
750 assert_eq!(
751 hook_result_action(&HookResult::Block {
752 reason: "stop".into(),
753 }),
754 "block"
755 );
756 assert_eq!(
757 hook_result_action(&HookResult::Inject {
758 content: "context".into(),
759 }),
760 "inject"
761 );
762 assert_eq!(
763 hook_result_action(&HookResult::Confirm {
764 message: "Proceed?".into(),
765 }),
766 "confirm"
767 );
768 assert_eq!(
769 hook_result_action(&HookResult::Modify {
770 input: serde_json::json!({"command": "echo safe"}),
771 }),
772 "modify"
773 );
774 }
775
776 #[tokio::test]
777 async fn empty_bus_returns_continue() {
778 let bus = HookBus::new();
779 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
780 let result = bus.emit(&event).await;
781 assert!(matches!(result, HookResult::Continue));
782 }
783
784 #[tokio::test]
785 async fn handler_receives_events() {
786 let bus = HookBus::new();
787 let handler = TestHandler::new("test-ext", HookResult::Continue);
788 let perms = perms_with(&[Permission::ToolsIntercept]);
789
790 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
791 .await
792 .unwrap();
793
794 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
795 bus.emit(&event).await;
796
797 assert_eq!(handler.calls(), 1);
798 }
799
800 #[tokio::test]
801 async fn confirm_stops_chain_for_before_tool_call() {
802 let bus = HookBus::new();
803 let confirmer = TestHandler::new(
804 "confirmer",
805 HookResult::Confirm {
806 message: "Run this command?".into(),
807 },
808 );
809 let after = TestHandler::new("after", HookResult::Continue);
810 let perms = perms_with(&[Permission::ToolsIntercept]);
811
812 bus.subscribe(
813 HookKind::BeforeToolCall,
814 confirmer.clone(),
815 None,
816 None,
817 perms.clone(),
818 )
819 .await
820 .unwrap();
821 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
822 .await
823 .unwrap();
824
825 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
826 let result = bus.emit(&event).await;
827
828 assert!(matches!(result, HookResult::Confirm { .. }));
829 assert_eq!(confirmer.calls(), 1);
830 assert_eq!(after.calls(), 0);
831 }
832
833 #[tokio::test]
834 async fn confirm_is_ignored_for_non_tool_hooks() {
835 let bus = HookBus::new();
836 let confirmer = TestHandler::new(
837 "confirmer",
838 HookResult::Confirm {
839 message: "Not allowed here".into(),
840 },
841 );
842 let perms = perms_with(&[Permission::LlmContent]);
843
844 bus.subscribe(
845 HookKind::BeforeMessage,
846 confirmer.clone(),
847 None,
848 None,
849 perms,
850 )
851 .await
852 .unwrap();
853
854 let event = HookEvent::before_message("hello");
855 let result = bus.emit(&event).await;
856
857 assert!(matches!(result, HookResult::Continue));
858 assert_eq!(confirmer.calls(), 1);
859 }
860
861 #[tokio::test]
862 async fn block_stops_chain() {
863 let bus = HookBus::new();
864 let blocker = TestHandler::new(
865 "blocker",
866 HookResult::Block {
867 reason: "dangerous".into(),
868 },
869 );
870 let after = TestHandler::new("after", HookResult::Continue);
871 let perms = perms_with(&[Permission::ToolsIntercept]);
872
873 bus.subscribe(
874 HookKind::BeforeToolCall,
875 blocker.clone(),
876 None,
877 None,
878 perms.clone(),
879 )
880 .await
881 .unwrap();
882 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
883 .await
884 .unwrap();
885
886 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
887 let result = bus.emit(&event).await;
888
889 assert!(matches!(result, HookResult::Block { .. }));
890 assert_eq!(blocker.calls(), 1);
891 assert_eq!(after.calls(), 0); }
893
894 #[tokio::test]
895 async fn modify_stops_chain() {
896 let bus = HookBus::new();
897 let modifier = TestHandler::new(
898 "modifier",
899 HookResult::Modify {
900 input: serde_json::json!({"command": "echo safe"}),
901 },
902 );
903 let after = TestHandler::new(
904 "after",
905 HookResult::Block {
906 reason: "should not run".into(),
907 },
908 );
909 let perms = perms_with(&[Permission::ToolsIntercept]);
910
911 bus.subscribe(
912 HookKind::BeforeToolCall,
913 modifier.clone(),
914 None,
915 None,
916 perms.clone(),
917 )
918 .await
919 .unwrap();
920 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
921 .await
922 .unwrap();
923
924 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
925 let result = bus.emit(&event).await;
926
927 assert!(
928 matches!(result, HookResult::Modify { input } if input == serde_json::json!({"command": "echo safe"}))
929 );
930 assert_eq!(modifier.calls(), 1);
931 assert_eq!(after.calls(), 0); }
933
934 #[tokio::test]
935 async fn tool_filter_only_matches_specified_tool() {
936 let bus = HookBus::new();
937 let handler = TestHandler::new("bash-only", HookResult::Continue);
938 let perms = perms_with(&[Permission::ToolsIntercept]);
939
940 bus.subscribe(
941 HookKind::AfterToolCall,
942 handler.clone(),
943 Some("bash".into()),
944 None,
945 perms,
946 )
947 .await
948 .unwrap();
949
950 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
952 bus.emit(&event).await;
953 assert_eq!(handler.calls(), 0);
954
955 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
957 bus.emit(&event).await;
958 assert_eq!(handler.calls(), 1);
959 }
960
961 #[tokio::test]
962 async fn permission_denied_rejects_subscribe() {
963 let bus = HookBus::new();
964 let handler = TestHandler::new("no-perms", HookResult::Continue);
965 let perms = PermissionSet::new(); let result = bus
968 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
969 .await;
970
971 assert!(result.is_err());
972 assert!(result.unwrap_err().contains("lacks permission"));
973 }
974
975 #[tokio::test]
976 async fn unsubscribe_removes_handlers() {
977 let bus = HookBus::new();
978 let handler = TestHandler::new("removable", HookResult::Continue);
979 let perms = perms_with(&[Permission::ToolsIntercept]);
980
981 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
982 .await
983 .unwrap();
984 assert_eq!(bus.handler_count().await, 1);
985
986 bus.unsubscribe_all("removable").await;
987 assert_eq!(bus.handler_count().await, 0);
988 }
989
990 #[tokio::test]
991 async fn subscriptions_for_lists_only_matching_extension() {
992 let bus = HookBus::new();
993 let alpha = TestHandler::new("alpha", HookResult::Continue);
994 let beta = TestHandler::new("beta", HookResult::Continue);
995 let perms = perms_with(&[Permission::ToolsIntercept]);
996
997 bus.subscribe(
998 HookKind::BeforeToolCall,
999 alpha.clone(),
1000 Some("bash".into()),
1001 None,
1002 perms.clone(),
1003 )
1004 .await
1005 .unwrap();
1006 bus.subscribe(
1007 HookKind::AfterToolCall,
1008 alpha.clone(),
1009 None,
1010 None,
1011 perms.clone(),
1012 )
1013 .await
1014 .unwrap();
1015 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1016 .await
1017 .unwrap();
1018
1019 let alpha_subs = bus.subscriptions_for("alpha").await;
1020 assert_eq!(alpha_subs.len(), 2);
1021 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1023 assert_eq!(alpha_subs[0].1, None);
1024 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1025 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1026
1027 let beta_subs = bus.subscriptions_for("beta").await;
1028 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1029
1030 let none_subs = bus.subscriptions_for("ghost").await;
1031 assert!(none_subs.is_empty());
1032 }
1033
1034 #[tokio::test]
1035 async fn is_empty_reflects_state() {
1036 let bus = HookBus::new();
1037 assert!(bus.is_empty().await);
1038
1039 let handler = TestHandler::new("ext", HookResult::Continue);
1040 let perms = perms_with(&[Permission::ToolsIntercept]);
1041 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1042 .await
1043 .unwrap();
1044 assert!(!bus.is_empty().await);
1045 }
1046
1047 #[derive(Debug)]
1052 struct RecordingHandler {
1053 id: String,
1054 recorded_len: Arc<AtomicUsize>,
1055 result: HookResult,
1056 }
1057 impl RecordingHandler {
1058 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1059 let recorded_len = Arc::new(AtomicUsize::new(0));
1060 let h = Arc::new(Self {
1061 id: id.to_string(),
1062 recorded_len: recorded_len.clone(),
1063 result,
1064 });
1065 (h, recorded_len)
1066 }
1067 }
1068 #[async_trait]
1069 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1070 fn id(&self) -> &str {
1071 &self.id
1072 }
1073 async fn handle(&self, event: &HookEvent) -> HookResult {
1074 if let Some(out) = event.tool_output.as_ref() {
1075 self.recorded_len.store(out.len(), Ordering::Relaxed);
1076 }
1077 self.result.clone()
1078 }
1079 async fn shutdown(&self) {}
1080 }
1081
1082 #[tokio::test]
1084 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1085 use crate::tools::Tool;
1086 let bus = std::sync::Arc::new(HookBus::new());
1087 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1088 bus.subscribe(
1089 HookKind::AfterToolCall,
1090 handler,
1091 None,
1092 None,
1093 perms_with(&[Permission::ToolsIntercept]),
1094 )
1095 .await
1096 .unwrap();
1097
1098 let ctx = crate::tools::ToolContext {
1099 channels: crate::tools::ToolChannels {
1100 tx_delta: None,
1101 tx_events: None,
1102 },
1103 capabilities: crate::tools::ToolCapabilities {
1104 launch_cancel: None,
1105 memory_backend: None,
1106 watcher_exit_path: None,
1107 tool_register_tx: None,
1108 session_manager: None,
1109 subagent_registry: None,
1110 event_queue: None,
1111 delegation_parent: None,
1112 codex_parent_plan: None,
1113 secret_prompt: None,
1114 orchestration: None,
1115 tool_activation: None,
1116 mcp_leases: None,
1117 extension_leases: None,
1118 memory_context: None,
1119 cwd: None,
1120 env: None,
1121 env_stripped: Vec::new(),
1122 env_warned: Default::default(),
1123 },
1124 limits: crate::tools::ToolLimits {
1125 max_tool_output: 30_000,
1126 max_tool_buffer: 256 * 1024,
1127 bash_timeout: 30,
1128 bash_max_timeout: 300,
1129 subagent_timeout: 300,
1130 },
1131 };
1132
1133 let bash = crate::tools::BashTool;
1134 let output = bash
1135 .execute(
1136 serde_json::json!({"command": "yes hello | head -c 150000"}),
1137 ctx,
1138 )
1139 .await
1140 .expect("bash should succeed");
1141
1142 let _ = crate::runtime::emit_after_tool_call(
1143 &bus,
1144 "bash",
1145 None,
1146 serde_json::json!({"command": "yes hello | head -c 150000"}),
1147 output,
1148 30_000,
1149 None,
1150 )
1151 .await;
1152
1153 let len = recorded_len.load(Ordering::Relaxed);
1154 assert!(
1155 len > 100_000,
1156 "after_tool_call handler was starved: received {len} bytes — \
1157 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1158 );
1159 }
1160
1161 #[tokio::test]
1165 async fn final_output_truncated_to_max_tool_output_after_hook() {
1166 let bus = std::sync::Arc::new(HookBus::new());
1167 let big: String = "x".repeat(100_000);
1168 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1169 bus.subscribe(
1170 HookKind::AfterToolCall,
1171 handler,
1172 None,
1173 None,
1174 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1175 )
1176 .await
1177 .unwrap();
1178
1179 let result = crate::runtime::emit_after_tool_call(
1180 &bus,
1181 "bash",
1182 None,
1183 serde_json::json!({}),
1184 "tiny original".to_string(),
1185 30_000,
1186 None,
1187 )
1188 .await;
1189
1190 assert!(
1191 result.len() <= 30_000 + 200,
1192 "post-hook output should be capped at max_tool_output (got {} bytes)",
1193 result.len()
1194 );
1195 }
1196
1197 #[tokio::test]
1200 async fn no_transform_extension_preserves_legacy_truncation() {
1201 let bus = std::sync::Arc::new(HookBus::new());
1202 let huge: String = "y".repeat(80_000);
1203
1204 let result = crate::runtime::emit_after_tool_call(
1205 &bus,
1206 "bash",
1207 None,
1208 serde_json::json!({}),
1209 huge.clone(),
1210 30_000,
1211 None,
1212 )
1213 .await;
1214
1215 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1216 assert_eq!(
1217 result, legacy,
1218 "no-extension path must be byte-identical to legacy truncation"
1219 );
1220 }
1221
1222 #[tokio::test]
1225 async fn emit_after_tool_call_truncates_char_boundary_safe() {
1226 let bus = std::sync::Arc::new(HookBus::new());
1228 let multibyte: String = "héllo🌟".repeat(5_000);
1229 assert!(multibyte.len() > 30_000);
1230
1231 let result = crate::runtime::emit_after_tool_call(
1232 &bus,
1233 "bash",
1234 None,
1235 serde_json::json!({}),
1236 multibyte,
1237 30_000,
1238 None,
1239 )
1240 .await;
1241 assert!(result.is_char_boundary(result.len()));
1242
1243 let bus2 = std::sync::Arc::new(HookBus::new());
1245 let big_multi = "✨".repeat(20_000);
1246 assert!(big_multi.len() > 30_000);
1247 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1248 bus2.subscribe(
1249 HookKind::AfterToolCall,
1250 handler,
1251 None,
1252 None,
1253 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1254 )
1255 .await
1256 .unwrap();
1257
1258 let result2 = crate::runtime::emit_after_tool_call(
1259 &bus2,
1260 "bash",
1261 None,
1262 serde_json::json!({}),
1263 "orig".to_string(),
1264 30_000,
1265 None,
1266 )
1267 .await;
1268 assert!(result2.is_char_boundary(result2.len()));
1269 }
1270
1271 #[tokio::test]
1272 async fn keyed_injection_isolated_per_session() {
1273 let bus = HookBus::new();
1274 bus.set_session_injection_for("s1", "one".into()).await;
1275 bus.set_session_injection_for("s2", "two".into()).await;
1276 assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
1277 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
1278 assert_eq!(bus.session_injection_for("s3").await, None);
1279 }
1280
1281 #[tokio::test]
1282 async fn clear_removes_only_that_session() {
1283 let bus = HookBus::new();
1284 bus.set_session_injection_for("s1", "one".into()).await;
1285 bus.set_session_injection_for("s2", "two".into()).await;
1286 bus.clear_session_injection("s1").await;
1287 assert_eq!(bus.session_injection_for("s1").await, None);
1288 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
1289 }
1290
1291 #[tokio::test]
1292 #[allow(deprecated)]
1293 async fn deprecated_shims_use_empty_key() {
1294 let bus = HookBus::new();
1295 bus.set_session_injection("legacy".into()).await;
1296 assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
1297 assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
1298 assert_eq!(bus.session_injection_for("s1").await, None);
1299 }
1300}