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 HookResult::ContextPhase { .. } => "context_phase",
42 }
43}
44
45fn accept_context_phase(
51 reg: &HandlerRegistration,
52 kind: HookKind,
53 phase: String,
54) -> Option<String> {
55 if !reg.permissions.has(Permission::SessionLifecycle) {
56 tracing::warn!(
57 hook = %kind.as_str(),
58 extension = %reg.handler.id(),
59 "Extension returned context_phase without session.lifecycle permission — ignoring report"
60 );
61 return None;
62 }
63 tracing::info!(
64 hook = %kind.as_str(),
65 extension = %reg.handler.id(),
66 phase = %crate::runtime::continuation::phase_for_log(&phase),
67 "Extension reported advisory context phase"
68 );
69 Some(phase)
70}
71
72#[derive(Clone)]
74pub struct HandlerRegistration {
75 pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
77 pub tool_filter: Option<String>,
79 pub matcher: Option<HookMatcher>,
81 pub permissions: PermissionSet,
83}
84
85pub struct HookBus {
90 handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
91 generations: std::sync::Mutex<HashMap<String, u64>>,
96 session_injection: RwLock<HashMap<String, String>>,
108}
109
110impl HookBus {
111 pub fn new() -> Self {
113 Self {
114 handlers: RwLock::new(HashMap::new()),
115 session_injection: RwLock::new(HashMap::new()),
116 generations: std::sync::Mutex::new(HashMap::new()),
117 }
118 }
119
120 pub fn extension_generation(&self, extension_id: &str) -> u64 {
122 self.generations
123 .lock()
124 .unwrap_or_else(|e| e.into_inner())
125 .get(extension_id)
126 .copied()
127 .unwrap_or(0)
128 }
129
130 pub async fn set_session_injection_for(&self, session_id: &str, content: String) {
137 self.session_injection
138 .write()
139 .await
140 .insert(session_id.to_string(), content);
141 }
142
143 pub async fn session_injection_for(&self, session_id: &str) -> Option<String> {
146 self.session_injection.read().await.get(session_id).cloned()
147 }
148
149 pub async fn clear_session_injection(&self, session_id: &str) {
151 self.session_injection.write().await.remove(session_id);
152 }
153
154 #[deprecated(note = "use set_session_injection_for")]
156 pub async fn set_session_injection(&self, content: String) {
157 self.set_session_injection_for("", content).await;
158 }
159
160 #[deprecated(note = "use session_injection_for")]
162 pub async fn session_injection(&self) -> Option<String> {
163 self.session_injection_for("").await
164 }
165
166 pub async fn subscribe(
171 &self,
172 kind: HookKind,
173 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
174 tool_filter: Option<String>,
175 matcher: Option<HookMatcher>,
176 permissions: PermissionSet,
177 ) -> Result<(), String> {
178 if !permissions.allows_hook(kind) {
180 return Err(format!(
181 "Extension '{}' lacks permission '{}' required for hook '{}'",
182 handler.id(),
183 kind.required_permission().as_str(),
184 kind.as_str(),
185 ));
186 }
187
188 let reg = HandlerRegistration {
189 handler,
190 tool_filter,
191 matcher,
192 permissions,
193 };
194
195 let mut handlers = self.handlers.write().await;
196 handlers.entry(kind).or_default().push(reg);
197 Ok(())
198 }
199
200 pub async fn emit(&self, event: &HookEvent) -> HookResult {
219 let registrations = {
224 let handlers = self.handlers.read().await;
225 match handlers.get(&event.kind) {
226 Some(regs) if !regs.is_empty() => regs.clone(),
227 _ => return HookResult::Continue, }
229 }; let mut injections: Vec<String> = Vec::new();
233 let mut context_phase: Option<String> = None;
236 let mut confirms: Vec<(String, String)> = Vec::new();
238 let mut source_keys: Vec<(String, u64)> = Vec::new();
240
241 let mut modified_event: Option<HookEvent> = None;
245 let mut modified_by: Vec<String> = Vec::new();
247
248 for reg in ®istrations {
249 let ev: &HookEvent = modified_event.as_ref().unwrap_or(event);
250 if let Some(ref filter) = reg.tool_filter {
254 let matches = match (&ev.tool_name, &ev.tool_runtime_name) {
255 (Some(api), Some(runtime)) => filter == api || filter == runtime,
256 (Some(api), None) => filter == api,
257 (None, Some(runtime)) => filter == runtime,
258 (None, None) => false,
259 };
260 if !matches {
261 continue;
262 }
263 }
264
265 if let Some(ref matcher) = reg.matcher {
266 if !matcher.matches(ev) {
267 continue;
268 }
269 }
270
271 let handler = reg.handler.clone();
273 let event_clone = ev.clone();
274 let trace_enabled = extensions_trace_enabled();
275 let started_at = trace_enabled.then(Instant::now);
276 let result = tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await;
277
278 if trace_enabled {
279 let health = reg.handler.health().await;
280 let health = health.as_str();
281 let restart_count = reg.handler.restart_count().await;
282 let duration_ms = started_at
283 .map(|start| start.elapsed().as_millis() as u64)
284 .unwrap_or(0);
285 match &result {
286 Ok(hook_result) => {
287 let action = hook_result_action(hook_result);
288 tracing::info!(
289 extension_trace = true,
290 hook = %ev.kind.as_str(),
291 extension = %reg.handler.id(),
292 action = action,
293 duration_ms = duration_ms,
294 health = health,
295 restart_count = restart_count,
296 "Extension hook trace"
297 );
298 }
299 Err(_) => {
300 tracing::warn!(
301 extension_trace = true,
302 hook = %ev.kind.as_str(),
303 extension = %reg.handler.id(),
304 action = "timeout",
305 duration_ms = duration_ms,
306 timeout_secs = HANDLER_TIMEOUT.as_secs(),
307 health = health,
308 restart_count = restart_count,
309 "Extension hook trace"
310 );
311 }
312 }
313 }
314
315 match result {
316 Ok(result) if !ev.kind.allows_result(&result) => {
317 tracing::warn!(
318 hook = %ev.kind.as_str(),
319 extension = %reg.handler.id(),
320 action = hook_result_action(&result),
321 "Extension returned action not allowed for hook — ignoring"
322 );
323 continue;
324 }
325 Ok(HookResult::Block { reason }) => {
326 tracing::info!(
327 hook = %ev.kind.as_str(),
328 extension = %reg.handler.id(),
329 reason = %reason,
330 "Hook blocked by extension"
331 );
332 return HookResult::Block { reason };
333 }
334 Ok(HookResult::Continue) => {}
335 Ok(HookResult::Inject { content }) => {
336 tracing::debug!(
337 hook = %ev.kind.as_str(),
338 extension = %reg.handler.id(),
339 len = content.len(),
340 "Extension injected context"
341 );
342 injections.push(content);
344 }
345 Ok(HookResult::Modify { input }) => {
346 if !confirms.is_empty() {
347 tracing::warn!(
351 hook = %ev.kind.as_str(),
352 extension = %reg.handler.id(),
353 confirming = ?confirms.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
354 "Extension Modify after a pending Confirm IGNORED: the user confirms the input as it was when the first Confirm was raised"
355 );
356 continue;
357 }
358 tracing::info!(
359 hook = %ev.kind.as_str(),
360 extension = %reg.handler.id(),
361 "Hook modified tool input by extension; later handlers vet the modified input"
362 );
363 let mut next = ev.clone();
364 next.tool_input = Some(input);
365 modified_event = Some(next);
366 let id = reg.handler.id().to_string();
367 if !modified_by.contains(&id) {
368 modified_by.push(id);
369 }
370 }
371 Ok(HookResult::Replace { output }) => {
372 if !reg.permissions.has(Permission::ToolsTransformOutput) {
378 tracing::warn!(
379 hook = %ev.kind.as_str(),
380 extension = %reg.handler.id(),
381 "Extension returned Replace without tools.transform_output permission — ignoring transform"
382 );
383 continue;
384 }
385 let observed_len = ev.tool_output.as_ref().map_or(0, String::len);
386 tracing::info!(
387 hook = %ev.kind.as_str(),
388 extension = %reg.handler.id(),
389 observed_len,
390 replacement_len = output.len(),
391 "Hook replaced tool output by extension"
392 );
393 let _ = crate::extensions::audit::record_tool_output_replace(
397 reg.handler.id(),
398 ev.kind.as_str(),
399 ev.tool_name.clone(),
400 observed_len,
401 output.len(),
402 );
403 return HookResult::Replace { output };
405 }
406 Ok(HookResult::Confirm { message, .. }) => {
407 tracing::info!(
408 hook = %ev.kind.as_str(),
409 extension = %reg.handler.id(),
410 "Hook requested confirmation by extension"
411 );
412 confirms.push((reg.handler.id().to_string(), message));
416 let id = reg.handler.id();
417 if !source_keys.iter().any(|(k, _)| k == id) {
418 source_keys.push((id.to_string(), self.extension_generation(id)));
419 }
420 }
421 Ok(HookResult::ContextPhase { phase }) => {
422 if let Some(phase) = accept_context_phase(reg, ev.kind, phase) {
425 if context_phase.is_none() {
426 context_phase = Some(phase);
427 } else {
428 tracing::debug!(
429 hook = %ev.kind.as_str(),
430 extension = %reg.handler.id(),
431 "Extension context_phase superseded by an earlier report — ignoring"
432 );
433 }
434 }
435 }
436 Err(_timeout) => {
437 tracing::warn!(
438 hook = %ev.kind.as_str(),
439 extension = %reg.handler.id(),
440 timeout_secs = HANDLER_TIMEOUT.as_secs(),
441 "Hook handler timed out — skipping"
442 );
443 }
445 }
446 }
447
448 if !confirms.is_empty() {
450 let tool = event
451 .tool_runtime_name
452 .clone()
453 .or_else(|| event.tool_name.clone());
454 let message = if confirms.len() == 1 {
455 confirms[0].1.clone()
456 } else {
457 confirms
458 .iter()
459 .map(|(id, m)| format!("[{id}] {m}"))
460 .collect::<Vec<_>>()
461 .join("\n\n")
462 };
463 let sources = source_keys.iter().map(|(id, _)| id.clone()).collect();
464 let generations = source_keys.iter().map(|(_, g)| *g).collect();
465 return HookResult::Confirm {
466 message,
467 sources,
468 generations,
469 tool,
470 modified_input: modified_event.and_then(|e| e.tool_input),
471 modified_by,
472 };
473 }
474 if let Some(input) = modified_event.and_then(|e| e.tool_input) {
475 return HookResult::Modify { input };
476 }
477
478 if !injections.is_empty() {
482 HookResult::Inject {
483 content: injections.join("\n\n"),
484 }
485 } else if let Some(phase) = context_phase {
486 HookResult::ContextPhase { phase }
487 } else {
488 HookResult::Continue
489 }
490 }
491
492 pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
516 assert!(
519 !matches!(
520 event.kind,
521 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
522 ),
523 "emit_concurrent must not be used for transform-capable hooks \
524 ({:?}); their Block/Modify/Replace/Inject results need the \
525 deterministic first-wins ordering of the sequential emit()",
526 event.kind,
527 );
528 let registrations = {
530 let handlers = self.handlers.read().await;
531 match handlers.get(&event.kind) {
532 Some(regs) if !regs.is_empty() => regs.clone(),
533 _ => return HookResult::Continue, }
535 };
536
537 let futures: Vec<_> =
539 registrations
540 .iter()
541 .filter(|reg| {
542 if let Some(ref filter) = reg.tool_filter {
544 match (&event.tool_name, &event.tool_runtime_name) {
545 (Some(api), Some(runtime)) => filter == api || filter == runtime,
546 (Some(api), None) => filter == api,
547 (None, Some(runtime)) => filter == runtime,
548 (None, None) => false,
549 }
550 } else {
551 true
552 }
553 })
554 .filter(|reg| reg.matcher.as_ref().is_none_or(|m| m.matches(event)))
555 .map(|reg| {
556 let handler = reg.handler.clone();
557 let event_clone = event.clone();
558 async move {
559 (
560 reg,
561 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone))
562 .await,
563 )
564 }
565 })
566 .collect();
567
568 let results = join_all(futures).await;
569
570 let mut injections: Vec<String> = Vec::new();
571 let mut context_phase: Option<String> = None;
574 for (reg, result) in results {
575 match result {
576 Ok(HookResult::Continue) => {}
577 Ok(HookResult::Block { reason }) => {
578 return HookResult::Block { reason };
579 }
580 Ok(HookResult::Inject { content }) => {
581 injections.push(content);
582 }
583 Ok(HookResult::Modify { input }) => {
584 return HookResult::Modify { input };
585 }
586 Ok(HookResult::Replace { output }) => {
587 return HookResult::Replace { output };
588 }
589 Ok(HookResult::Confirm { message, .. }) => {
590 return HookResult::Confirm {
591 message,
592 sources: vec![reg.handler.id().to_string()],
593 generations: vec![self.extension_generation(reg.handler.id())],
594 tool: event.tool_runtime_name.clone().or_else(|| event.tool_name.clone()),
595 modified_input: None,
596 modified_by: vec![],
597 };
598 }
599 Ok(result @ HookResult::ContextPhase { .. }) => {
600 if !event.kind.allows_result(&result) {
601 tracing::warn!(
602 hook = %event.kind.as_str(),
603 extension = %reg.handler.id(),
604 action = hook_result_action(&result),
605 "Extension returned action not allowed for hook — ignoring"
606 );
607 continue;
608 }
609 if let HookResult::ContextPhase { phase } = result {
610 if let Some(phase) = accept_context_phase(reg, event.kind, phase) {
611 context_phase.get_or_insert(phase);
612 }
613 }
614 }
615 Err(_timeout) => {
616 tracing::warn!(
617 hook = %event.kind.as_str(),
618 extension = %reg.handler.id(),
619 timeout_secs = HANDLER_TIMEOUT.as_secs(),
620 "Hook handler timed out in concurrent emit — skipping"
621 );
622 }
623 }
624 }
625
626 if !injections.is_empty() {
627 HookResult::Inject {
628 content: injections.join("\n\n"),
629 }
630 } else if let Some(phase) = context_phase {
631 HookResult::ContextPhase { phase }
632 } else {
633 HookResult::Continue
634 }
635 }
636
637 pub async fn unsubscribe_all(&self, extension_id: &str) {
639 *self
640 .generations
641 .lock()
642 .unwrap_or_else(|e| e.into_inner())
643 .entry(extension_id.to_string())
644 .or_insert(0) += 1;
645 let mut handlers = self.handlers.write().await;
646 for regs in handlers.values_mut() {
647 regs.retain(|r| r.handler.id() != extension_id);
648 }
649 }
650
651 pub async fn handler_count(&self) -> usize {
653 let handlers = self.handlers.read().await;
654 handlers.values().map(|v| v.len()).sum()
655 }
656
657 pub async fn is_empty(&self) -> bool {
659 let handlers = self.handlers.read().await;
660 handlers.values().all(|v| v.is_empty())
661 }
662
663 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
666 let handlers = self.handlers.read().await;
667 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
668 for (kind, regs) in handlers.iter() {
669 for reg in regs {
670 if reg.handler.id() == extension_id {
671 out.push((*kind, reg.tool_filter.clone()));
672 }
673 }
674 }
675 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
676 out
677 }
678}
679
680impl Default for HookBus {
681 fn default() -> Self {
682 Self::new()
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::extensions::hooks::events::HookEvent;
690 use crate::extensions::permissions::Permission;
691 use async_trait::async_trait;
692 use std::sync::atomic::{AtomicUsize, Ordering};
693
694 struct TestHandler {
696 id: String,
697 call_count: AtomicUsize,
698 result: HookResult,
699 }
700
701 impl TestHandler {
702 fn new(id: &str, result: HookResult) -> Arc<Self> {
703 Arc::new(Self {
704 id: id.to_string(),
705 call_count: AtomicUsize::new(0),
706 result,
707 })
708 }
709
710 fn calls(&self) -> usize {
711 self.call_count.load(Ordering::Relaxed)
712 }
713 }
714
715 #[async_trait]
716 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
717 fn id(&self) -> &str {
718 &self.id
719 }
720
721 async fn handle(&self, _event: &HookEvent) -> HookResult {
722 self.call_count.fetch_add(1, Ordering::Relaxed);
723 self.result.clone()
724 }
725
726 async fn shutdown(&self) {}
727 }
728
729 fn perms_with(perms: &[Permission]) -> PermissionSet {
730 let mut set = PermissionSet::new();
731 for p in perms {
732 set.grant(*p);
733 }
734 set
735 }
736
737 #[tokio::test]
741 #[serial_test::serial(synaps_base_dir)]
742 async fn after_tool_call_replace_substitutes_recorded_output() {
743 let base = crate::test_env::BaseDirGuard::new();
745 let bus = std::sync::Arc::new(HookBus::new());
746 let handler = TestHandler::new(
747 "compressor",
748 HookResult::Replace {
749 output: "COMPRESSED".into(),
750 },
751 );
752 bus.subscribe(
753 HookKind::AfterToolCall,
754 handler,
755 None,
756 None,
757 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
758 )
759 .await
760 .unwrap();
761
762 let recorded = crate::runtime::emit_after_tool_call(
763 &bus,
764 "bash",
765 None,
766 serde_json::json!({"command": "cat huge.log"}),
767 "RAW 10k lines".to_string(),
768 30_000,
769 None,
770 )
771 .await;
772
773 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
774 let audit = std::fs::read_to_string(
775 crate::extensions::audit::intercept_file_path_for(base.path()),
776 )
777 .expect("intercept audit must land in the isolated base dir");
778 assert!(audit.contains("\"plugin_id\":\"compressor\""));
779 }
780
781 #[tokio::test]
783 async fn after_tool_call_without_replace_keeps_original_output() {
784 let bus = std::sync::Arc::new(HookBus::new());
785 let recorded = crate::runtime::emit_after_tool_call(
786 &bus,
787 "bash",
788 None,
789 serde_json::json!({}),
790 "RAW".to_string(),
791 30_000,
792 None,
793 )
794 .await;
795
796 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
797 }
798
799 #[tokio::test]
803 #[serial_test::serial(synaps_base_dir)]
804 async fn replace_stops_chain_for_after_tool_call() {
805 let _base = crate::test_env::BaseDirGuard::new();
807 let bus = HookBus::new();
808 let first = TestHandler::new(
809 "first",
810 HookResult::Replace {
811 output: "FIRST".into(),
812 },
813 );
814 let second = TestHandler::new(
815 "second",
816 HookResult::Replace {
817 output: "SECOND".into(),
818 },
819 );
820 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
821
822 bus.subscribe(
823 HookKind::AfterToolCall,
824 first.clone(),
825 None,
826 None,
827 perms.clone(),
828 )
829 .await
830 .unwrap();
831 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
832 .await
833 .unwrap();
834
835 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
836 let result = bus.emit(&event).await;
837
838 assert_eq!(
839 result,
840 HookResult::Replace {
841 output: "FIRST".into()
842 }
843 );
844 assert_eq!(first.calls(), 1);
845 assert_eq!(second.calls(), 0); }
847
848 #[tokio::test]
852 async fn replace_without_transform_permission_is_ignored() {
853 let bus = std::sync::Arc::new(HookBus::new());
854 let handler = TestHandler::new(
855 "observer-only",
856 HookResult::Replace {
857 output: "SNEAKY".into(),
858 },
859 );
860 bus.subscribe(
862 HookKind::AfterToolCall,
863 handler.clone(),
864 None,
865 None,
866 perms_with(&[Permission::ToolsIntercept]),
867 )
868 .await
869 .unwrap();
870
871 let recorded = crate::runtime::emit_after_tool_call(
872 &bus,
873 "bash",
874 None,
875 serde_json::json!({}),
876 "ORIGINAL".to_string(),
877 30_000,
878 None,
879 )
880 .await;
881
882 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
883 assert_eq!(
884 recorded, "ORIGINAL",
885 "Replace without tools.transform_output must be ignored — original preserved"
886 );
887 }
888
889 fn phase(p: &str) -> HookResult {
892 HookResult::ContextPhase { phase: p.into() }
893 }
894
895 fn message_complete_event() -> HookEvent {
896 HookEvent::on_message_complete(
897 "done",
898 serde_json::json!({"content_block_count": 1, "has_tool_use": false}),
899 )
900 }
901
902 #[tokio::test]
905 async fn context_phase_accepted_with_session_lifecycle() {
906 let bus = HookBus::new();
907 let reporter = TestHandler::new("reporter", phase("new_task"));
908 bus.subscribe(
909 HookKind::OnMessageComplete,
910 reporter.clone(),
911 None,
912 None,
913 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
914 )
915 .await
916 .unwrap();
917
918 let result = bus.emit(&message_complete_event()).await;
919 assert_eq!(result, phase("new_task"));
920 assert_eq!(reporter.calls(), 1);
921 }
922
923 #[tokio::test]
926 async fn context_phase_ignored_without_session_lifecycle() {
927 let bus = HookBus::new();
928 let reporter = TestHandler::new("observer-only", phase("new_task"));
929 bus.subscribe(
930 HookKind::OnMessageComplete,
931 reporter.clone(),
932 None,
933 None,
934 perms_with(&[Permission::LlmContent]),
935 )
936 .await
937 .unwrap();
938
939 let result = bus.emit(&message_complete_event()).await;
940 assert_eq!(result, HookResult::Continue);
941 assert_eq!(reporter.calls(), 1, "handler still runs (it's subscribed)");
942 }
943
944 #[tokio::test]
948 async fn context_phase_does_not_stop_chain_and_first_valid_wins() {
949 let bus = HookBus::new();
950 let unprivileged = TestHandler::new("unprivileged", phase("plan"));
951 let first = TestHandler::new("first", phase("new_task"));
952 let second = TestHandler::new("second", phase("wrap_up"));
953 let observer = TestHandler::new("observer", HookResult::Continue);
954 let both = || perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]);
955
956 bus.subscribe(
957 HookKind::OnMessageComplete,
958 unprivileged.clone(),
959 None,
960 None,
961 perms_with(&[Permission::LlmContent]),
962 )
963 .await
964 .unwrap();
965 for handler in [first.clone(), second.clone()] {
966 bus.subscribe(HookKind::OnMessageComplete, handler, None, None, both())
967 .await
968 .unwrap();
969 }
970 bus.subscribe(
971 HookKind::OnMessageComplete,
972 observer.clone(),
973 None,
974 None,
975 perms_with(&[Permission::LlmContent]),
976 )
977 .await
978 .unwrap();
979
980 let result = bus.emit(&message_complete_event()).await;
981 assert_eq!(result, phase("new_task"), "first valid report wins");
982 assert_eq!(unprivileged.calls(), 1);
983 assert_eq!(first.calls(), 1);
984 assert_eq!(second.calls(), 1, "later reporter still runs");
985 assert_eq!(observer.calls(), 1, "later observer still runs");
986 }
987
988 #[tokio::test]
991 async fn context_phase_survives_ignored_block_on_message_complete() {
992 let bus = HookBus::new();
993 let blocker = TestHandler::new(
994 "blocker",
995 HookResult::Block {
996 reason: "nope".into(),
997 },
998 );
999 let reporter = TestHandler::new("reporter", phase("execute"));
1000 bus.subscribe(
1001 HookKind::OnMessageComplete,
1002 blocker.clone(),
1003 None,
1004 None,
1005 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1006 )
1007 .await
1008 .unwrap();
1009 bus.subscribe(
1010 HookKind::OnMessageComplete,
1011 reporter.clone(),
1012 None,
1013 None,
1014 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1015 )
1016 .await
1017 .unwrap();
1018
1019 let result = bus.emit(&message_complete_event()).await;
1020 assert_eq!(result, phase("execute"));
1021 assert_eq!(blocker.calls(), 1);
1022 assert_eq!(reporter.calls(), 1);
1023 }
1024
1025 #[tokio::test]
1028 async fn context_phase_not_allowed_on_other_hooks() {
1029 let bus = HookBus::new();
1030 let all_perms = perms_with(&[
1031 Permission::ToolsIntercept,
1032 Permission::LlmContent,
1033 Permission::SessionLifecycle,
1034 ]);
1035 let handlers: Vec<(HookKind, HookEvent, Arc<TestHandler>)> = vec![
1036 (
1037 HookKind::BeforeToolCall,
1038 HookEvent::before_tool_call("bash", serde_json::json!({})),
1039 TestHandler::new("btc", phase("new_task")),
1040 ),
1041 (
1042 HookKind::AfterToolCall,
1043 HookEvent::after_tool_call("bash", serde_json::json!({}), "o".into()),
1044 TestHandler::new("atc", phase("new_task")),
1045 ),
1046 (
1047 HookKind::BeforeMessage,
1048 HookEvent::before_message("hi"),
1049 TestHandler::new("bm", phase("new_task")),
1050 ),
1051 (
1052 HookKind::OnCompaction,
1053 HookEvent::on_compaction("a", "b", "s", 1, serde_json::json!({})),
1054 TestHandler::new("oc", phase("new_task")),
1055 ),
1056 (
1057 HookKind::OnSessionStart,
1058 HookEvent::on_session_start("s"),
1059 TestHandler::new("oss", phase("new_task")),
1060 ),
1061 (
1062 HookKind::OnSessionEnd,
1063 HookEvent::on_session_end("s", None),
1064 TestHandler::new("ose", phase("new_task")),
1065 ),
1066 ];
1067 for (kind, _, handler) in &handlers {
1068 bus.subscribe(*kind, handler.clone(), None, None, all_perms.clone())
1069 .await
1070 .unwrap();
1071 }
1072 for (kind, event, handler) in &handlers {
1073 let result = bus.emit(event).await;
1074 assert_eq!(result, HookResult::Continue, "{}", kind.as_str());
1075 assert_eq!(handler.calls(), 1, "{}", kind.as_str());
1076 }
1077 let end = HookEvent::on_session_end("s", None);
1079 assert_eq!(bus.emit_concurrent(&end).await, HookResult::Continue);
1080 }
1081
1082 #[tokio::test]
1085 async fn context_phase_concurrent_emit_two_key_gate() {
1086 let bus = HookBus::new();
1087 let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1088 let reporter = TestHandler::new("reporter", phase("wrap_up"));
1089 bus.subscribe(
1090 HookKind::OnMessageComplete,
1091 unprivileged.clone(),
1092 None,
1093 None,
1094 perms_with(&[Permission::LlmContent]),
1095 )
1096 .await
1097 .unwrap();
1098 bus.subscribe(
1099 HookKind::OnMessageComplete,
1100 reporter.clone(),
1101 None,
1102 None,
1103 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1104 )
1105 .await
1106 .unwrap();
1107
1108 let result = bus.emit_concurrent(&message_complete_event()).await;
1109 assert_eq!(result, phase("wrap_up"));
1110 assert_eq!(unprivileged.calls(), 1);
1111 assert_eq!(reporter.calls(), 1);
1112 }
1113
1114 #[test]
1115 fn trace_env_value_parser_accepts_common_truthy_values() {
1116 for value in ["1", "true", "TRUE", "yes", "on"] {
1117 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1118 assert!(
1119 extensions_trace_enabled(),
1120 "{value} should enable trace mode"
1121 );
1122 }
1123
1124 for value in ["", "0", "false", "off", "no"] {
1125 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1126 assert!(
1127 !extensions_trace_enabled(),
1128 "{value:?} should not enable trace mode"
1129 );
1130 }
1131 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
1132 }
1133
1134 #[tokio::test]
1135 async fn matcher_skips_handler_when_input_does_not_contain_value() {
1136 let bus = HookBus::new();
1137 let handler = TestHandler::new(
1138 "matcher",
1139 HookResult::Block {
1140 reason: "matched".into(),
1141 },
1142 );
1143 let mut perms = PermissionSet::new();
1144 perms.grant(Permission::ToolsIntercept);
1145 bus.subscribe(
1146 HookKind::BeforeToolCall,
1147 handler.clone(),
1148 None,
1149 Some(HookMatcher {
1150 input_contains: Some("danger".to_string()),
1151 input_equals: None,
1152 }),
1153 perms,
1154 )
1155 .await
1156 .unwrap();
1157
1158 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
1159 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
1160
1161 let danger =
1162 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
1163 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
1164 }
1165
1166 #[test]
1167 fn hook_result_action_names_are_stable_for_trace_logs() {
1168 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
1169 assert_eq!(
1170 hook_result_action(&HookResult::Block {
1171 reason: "stop".into(),
1172 }),
1173 "block"
1174 );
1175 assert_eq!(
1176 hook_result_action(&HookResult::Inject {
1177 content: "context".into(),
1178 }),
1179 "inject"
1180 );
1181 assert_eq!(
1182 hook_result_action(&HookResult::confirm("Proceed?")),
1183 "confirm"
1184 );
1185 assert_eq!(
1186 hook_result_action(&HookResult::Modify {
1187 input: serde_json::json!({"command": "echo safe"}),
1188 }),
1189 "modify"
1190 );
1191 }
1192
1193 #[tokio::test]
1194 async fn empty_bus_returns_continue() {
1195 let bus = HookBus::new();
1196 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1197 let result = bus.emit(&event).await;
1198 assert!(matches!(result, HookResult::Continue));
1199 }
1200
1201 #[tokio::test]
1202 async fn handler_receives_events() {
1203 let bus = HookBus::new();
1204 let handler = TestHandler::new("test-ext", HookResult::Continue);
1205 let perms = perms_with(&[Permission::ToolsIntercept]);
1206
1207 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1208 .await
1209 .unwrap();
1210
1211 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1212 bus.emit(&event).await;
1213
1214 assert_eq!(handler.calls(), 1);
1215 }
1216
1217 async fn bus_with(handlers: Vec<Arc<TestHandler>>) -> HookBus {
1218 let bus = HookBus::new();
1219 let perms = perms_with(&[Permission::ToolsIntercept]);
1220 for h in handlers {
1221 bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1222 .await
1223 .unwrap();
1224 }
1225 bus
1226 }
1227
1228 #[tokio::test]
1231 async fn confirm_does_not_stop_chain_for_before_tool_call() {
1232 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run this command?"));
1233 let after = TestHandler::new("after", HookResult::Continue);
1234 let bus = bus_with(vec![confirmer.clone(), after.clone()]).await;
1235
1236 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1237 let result = bus.emit(&event).await;
1238
1239 assert!(matches!(result, HookResult::Confirm { .. }));
1240 assert_eq!(confirmer.calls(), 1);
1241 assert_eq!(after.calls(), 1, "later handlers are still evaluated");
1242 }
1243
1244 #[tokio::test]
1246 async fn later_block_wins_over_earlier_confirm() {
1247 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1248 let blocker = TestHandler::new(
1249 "policy",
1250 HookResult::Block {
1251 reason: "policy veto".into(),
1252 },
1253 );
1254 let bus = bus_with(vec![confirmer, blocker]).await;
1255 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1256 assert!(matches!(
1257 bus.emit(&event).await,
1258 HookResult::Block { reason } if reason == "policy veto"
1259 ));
1260 }
1261
1262 #[tokio::test]
1265 async fn confirms_from_several_extensions_merge_sources() {
1266 let a = TestHandler::new("ext-a", HookResult::confirm("A asks"));
1267 let b = TestHandler::new("ext-b", HookResult::confirm("B asks"));
1268 let bus = bus_with(vec![a, b]).await;
1269 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1270 match bus.emit(&event).await {
1271 HookResult::Confirm {
1272 message, sources, ..
1273 } => {
1274 assert_eq!(sources, vec!["ext-a".to_string(), "ext-b".to_string()]);
1275 assert!(message.contains("A asks") && message.contains("B asks"), "{message}");
1276 assert!(message.contains("[ext-a]") && message.contains("[ext-b]"), "{message}");
1277 }
1278 other => panic!("expected merged Confirm, got {other:?}"),
1279 }
1280 }
1281
1282 #[tokio::test]
1285 async fn armed_latch_never_skips_a_later_block() {
1286 let confirmer = TestHandler::new("jev", HookResult::confirm("Run?"));
1287 let blocker = TestHandler::new(
1288 "policy",
1289 HookResult::Block {
1290 reason: "policy veto".into(),
1291 },
1292 );
1293 let bus = bus_with(vec![confirmer, blocker.clone()]).await;
1294 let latch = crate::tools::SessionConfirmLatch::new();
1295 latch.allow(&[("jev".to_string(), 0)]);
1296 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1297 let decision = crate::runtime::resolve_before_tool_call_decision(
1298 serde_json::json!({}),
1299 bus.emit(&event).await,
1300 None,
1301 false,
1302 Some(&latch),
1303 )
1304 .await;
1305 assert_eq!(blocker.calls(), 1);
1306 assert!(matches!(
1307 decision,
1308 crate::runtime::BeforeToolCallDecision::Block { reason } if reason == "policy veto"
1309 ));
1310 }
1311
1312 struct InputSeen {
1314 id: String,
1315 seen: std::sync::Mutex<Option<serde_json::Value>>,
1316 result: HookResult,
1317 }
1318 #[async_trait]
1319 impl crate::extensions::runtime::ExtensionHandler for InputSeen {
1320 fn id(&self) -> &str {
1321 &self.id
1322 }
1323 async fn handle(&self, event: &HookEvent) -> HookResult {
1324 *self.seen.lock().unwrap() = event.tool_input.clone();
1325 self.result.clone()
1326 }
1327 async fn shutdown(&self) {}
1328 }
1329 fn input_seen(id: &str, result: HookResult) -> Arc<InputSeen> {
1330 Arc::new(InputSeen {
1331 id: id.into(),
1332 seen: std::sync::Mutex::new(None),
1333 result,
1334 })
1335 }
1336 fn modify(cmd: &str) -> HookResult {
1337 HookResult::Modify {
1338 input: serde_json::json!({ "command": cmd }),
1339 }
1340 }
1341 async fn bus_of(handlers: Vec<Arc<dyn crate::extensions::runtime::ExtensionHandler>>) -> HookBus {
1342 let bus = HookBus::new();
1343 let perms = perms_with(&[Permission::ToolsIntercept]);
1344 for h in handlers {
1345 bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1346 .await
1347 .unwrap();
1348 }
1349 bus
1350 }
1351
1352 #[tokio::test]
1355 async fn modify_then_block_blocks_and_blocker_vets_modified_input() {
1356 let blocker = input_seen(
1357 "policy",
1358 HookResult::Block {
1359 reason: "veto".into(),
1360 },
1361 );
1362 let bus = bus_of(vec![input_seen("rewriter", modify("rm -rf /")), blocker.clone()]).await;
1363 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1364 assert!(matches!(bus.emit(&event).await, HookResult::Block { reason } if reason == "veto"));
1365 assert_eq!(
1366 blocker.seen.lock().unwrap().clone(),
1367 Some(serde_json::json!({"command": "rm -rf /"})),
1368 "the blocker must vet the rewritten input, not the original"
1369 );
1370 }
1371
1372 #[tokio::test]
1375 async fn modify_then_confirm_prompts_with_modified_input() {
1376 let confirmer = input_seen("jev", HookResult::confirm("ok?"));
1377 let bus = bus_of(vec![input_seen("rewriter", modify("echo safe")), confirmer.clone()]).await;
1378 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1379 let result = bus.emit(&event).await;
1380 assert_eq!(
1381 confirmer.seen.lock().unwrap().clone(),
1382 Some(serde_json::json!({"command": "echo safe"}))
1383 );
1384 match &result {
1385 HookResult::Confirm {
1386 sources,
1387 modified_input,
1388 ..
1389 } => {
1390 assert_eq!(sources, &vec!["jev".to_string()]);
1391 assert_eq!(
1392 modified_input,
1393 &Some(serde_json::json!({"command": "echo safe"}))
1394 );
1395 }
1396 other => panic!("expected Confirm, got {other:?}"),
1397 }
1398 let decision = crate::runtime::resolve_before_tool_call_decision(
1400 serde_json::json!({"command": "ls"}),
1401 result,
1402 None,
1403 true,
1404 None,
1405 )
1406 .await;
1407 assert!(matches!(
1408 decision,
1409 crate::runtime::BeforeToolCallDecision::Continue { input }
1410 if input == serde_json::json!({"command": "echo safe"})
1411 ));
1412 }
1413
1414 #[tokio::test]
1417 async fn modifiers_compose_and_final_input_is_returned() {
1418 let second = input_seen("second", modify("two"));
1419 let bus = bus_of(vec![input_seen("first", modify("one")), second.clone()]).await;
1420 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "zero"}));
1421 assert!(matches!(
1422 bus.emit(&event).await,
1423 HookResult::Modify { input } if input == serde_json::json!({"command": "two"})
1424 ));
1425 assert_eq!(
1426 second.seen.lock().unwrap().clone(),
1427 Some(serde_json::json!({"command": "one"}))
1428 );
1429 }
1430
1431 #[tokio::test]
1434 async fn reinstall_under_same_id_gets_a_new_confirm_generation() {
1435 let bus = bus_with(vec![TestHandler::new("jev", HookResult::confirm("ok?"))]).await;
1436 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1437 let gens = |r: HookResult| match r {
1438 HookResult::Confirm { generations, .. } => generations,
1439 other => panic!("{other:?}"),
1440 };
1441 assert_eq!(gens(bus.emit(&event).await), vec![0]);
1442 bus.unsubscribe_all("jev").await;
1443 bus.subscribe(
1444 HookKind::BeforeToolCall,
1445 TestHandler::new("jev", HookResult::confirm("ok?")),
1446 None,
1447 None,
1448 perms_with(&[Permission::ToolsIntercept]),
1449 )
1450 .await
1451 .unwrap();
1452 let latch = crate::tools::SessionConfirmLatch::new();
1453 latch.allow(&[("jev".to_string(), 0)]);
1454 let result = bus.emit(&event).await;
1455 let decision = crate::runtime::resolve_before_tool_call_decision(
1456 serde_json::json!({}),
1457 result.clone(),
1458 None,
1459 false,
1460 Some(&latch),
1461 )
1462 .await;
1463 assert_eq!(gens(result), vec![1]);
1464 assert!(
1465 matches!(decision, crate::runtime::BeforeToolCallDecision::Block { .. }),
1466 "old consent must not carry over to the reinstalled extension"
1467 );
1468 }
1469
1470 #[tokio::test]
1474 async fn modify_after_confirm_is_ignored() {
1475 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1476 let modifier = TestHandler::new(
1477 "modifier",
1478 HookResult::Modify {
1479 input: serde_json::json!({"command": "echo other"}),
1480 },
1481 );
1482 let bus = bus_with(vec![confirmer, modifier]).await;
1483 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1484 assert!(matches!(
1485 bus.emit(&event).await,
1486 HookResult::Confirm { modified_input: None, .. }
1487 ), "Confirm→Modify: the modify is ignored (logged), the original input is confirmed");
1488 }
1489
1490 #[tokio::test]
1491 async fn confirm_is_stamped_with_source_extension_and_tool() {
1492 let bus = HookBus::new();
1493 let confirmer = TestHandler::new("jev", HookResult::confirm("Run ls?"));
1494 let perms = perms_with(&[Permission::ToolsIntercept]);
1495 bus.subscribe(HookKind::BeforeToolCall, confirmer, None, None, perms)
1496 .await
1497 .unwrap();
1498 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1499 match bus.emit(&event).await {
1500 HookResult::Confirm {
1501 message,
1502 sources,
1503 tool,
1504 ..
1505 } => {
1506 assert_eq!(message, "Run ls?");
1507 assert_eq!(sources, vec!["jev".to_string()]);
1508 assert_eq!(tool.as_deref(), Some("bash"));
1509 }
1510 other => panic!("expected Confirm, got {other:?}"),
1511 }
1512 }
1513
1514 #[tokio::test]
1515 async fn confirm_is_ignored_for_non_tool_hooks() {
1516 let bus = HookBus::new();
1517 let confirmer = TestHandler::new(
1518 "confirmer",
1519 HookResult::confirm("Not allowed here"),
1520 );
1521 let perms = perms_with(&[Permission::LlmContent]);
1522
1523 bus.subscribe(
1524 HookKind::BeforeMessage,
1525 confirmer.clone(),
1526 None,
1527 None,
1528 perms,
1529 )
1530 .await
1531 .unwrap();
1532
1533 let event = HookEvent::before_message("hello");
1534 let result = bus.emit(&event).await;
1535
1536 assert!(matches!(result, HookResult::Continue));
1537 assert_eq!(confirmer.calls(), 1);
1538 }
1539
1540 #[tokio::test]
1541 async fn block_stops_chain() {
1542 let bus = HookBus::new();
1543 let blocker = TestHandler::new(
1544 "blocker",
1545 HookResult::Block {
1546 reason: "dangerous".into(),
1547 },
1548 );
1549 let after = TestHandler::new("after", HookResult::Continue);
1550 let perms = perms_with(&[Permission::ToolsIntercept]);
1551
1552 bus.subscribe(
1553 HookKind::BeforeToolCall,
1554 blocker.clone(),
1555 None,
1556 None,
1557 perms.clone(),
1558 )
1559 .await
1560 .unwrap();
1561 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
1562 .await
1563 .unwrap();
1564
1565 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1566 let result = bus.emit(&event).await;
1567
1568 assert!(matches!(result, HookResult::Block { .. }));
1569 assert_eq!(blocker.calls(), 1);
1570 assert_eq!(after.calls(), 0); }
1572
1573 #[tokio::test]
1576 async fn modify_does_not_stop_chain_later_block_wins() {
1577 let modifier = TestHandler::new(
1578 "modifier",
1579 HookResult::Modify {
1580 input: serde_json::json!({"command": "echo safe"}),
1581 },
1582 );
1583 let after = TestHandler::new(
1584 "after",
1585 HookResult::Block {
1586 reason: "vetoed".into(),
1587 },
1588 );
1589 let bus = bus_with(vec![modifier.clone(), after.clone()]).await;
1590 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
1591 let result = bus.emit(&event).await;
1592 assert!(matches!(result, HookResult::Block { reason } if reason == "vetoed"));
1593 assert_eq!(modifier.calls(), 1);
1594 assert_eq!(after.calls(), 1);
1595 }
1596
1597 #[tokio::test]
1598 async fn tool_filter_only_matches_specified_tool() {
1599 let bus = HookBus::new();
1600 let handler = TestHandler::new("bash-only", HookResult::Continue);
1601 let perms = perms_with(&[Permission::ToolsIntercept]);
1602
1603 bus.subscribe(
1604 HookKind::AfterToolCall,
1605 handler.clone(),
1606 Some("bash".into()),
1607 None,
1608 perms,
1609 )
1610 .await
1611 .unwrap();
1612
1613 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
1615 bus.emit(&event).await;
1616 assert_eq!(handler.calls(), 0);
1617
1618 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
1620 bus.emit(&event).await;
1621 assert_eq!(handler.calls(), 1);
1622 }
1623
1624 #[tokio::test]
1625 async fn permission_denied_rejects_subscribe() {
1626 let bus = HookBus::new();
1627 let handler = TestHandler::new("no-perms", HookResult::Continue);
1628 let perms = PermissionSet::new(); let result = bus
1631 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1632 .await;
1633
1634 assert!(result.is_err());
1635 assert!(result.unwrap_err().contains("lacks permission"));
1636 }
1637
1638 #[tokio::test]
1639 async fn unsubscribe_removes_handlers() {
1640 let bus = HookBus::new();
1641 let handler = TestHandler::new("removable", HookResult::Continue);
1642 let perms = perms_with(&[Permission::ToolsIntercept]);
1643
1644 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1645 .await
1646 .unwrap();
1647 assert_eq!(bus.handler_count().await, 1);
1648
1649 bus.unsubscribe_all("removable").await;
1650 assert_eq!(bus.handler_count().await, 0);
1651 }
1652
1653 #[tokio::test]
1654 async fn subscriptions_for_lists_only_matching_extension() {
1655 let bus = HookBus::new();
1656 let alpha = TestHandler::new("alpha", HookResult::Continue);
1657 let beta = TestHandler::new("beta", HookResult::Continue);
1658 let perms = perms_with(&[Permission::ToolsIntercept]);
1659
1660 bus.subscribe(
1661 HookKind::BeforeToolCall,
1662 alpha.clone(),
1663 Some("bash".into()),
1664 None,
1665 perms.clone(),
1666 )
1667 .await
1668 .unwrap();
1669 bus.subscribe(
1670 HookKind::AfterToolCall,
1671 alpha.clone(),
1672 None,
1673 None,
1674 perms.clone(),
1675 )
1676 .await
1677 .unwrap();
1678 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1679 .await
1680 .unwrap();
1681
1682 let alpha_subs = bus.subscriptions_for("alpha").await;
1683 assert_eq!(alpha_subs.len(), 2);
1684 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1686 assert_eq!(alpha_subs[0].1, None);
1687 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1688 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1689
1690 let beta_subs = bus.subscriptions_for("beta").await;
1691 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1692
1693 let none_subs = bus.subscriptions_for("ghost").await;
1694 assert!(none_subs.is_empty());
1695 }
1696
1697 #[tokio::test]
1698 async fn is_empty_reflects_state() {
1699 let bus = HookBus::new();
1700 assert!(bus.is_empty().await);
1701
1702 let handler = TestHandler::new("ext", HookResult::Continue);
1703 let perms = perms_with(&[Permission::ToolsIntercept]);
1704 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1705 .await
1706 .unwrap();
1707 assert!(!bus.is_empty().await);
1708 }
1709
1710 #[cfg(feature = "command-exec")]
1715 #[derive(Debug)]
1716 struct RecordingHandler {
1717 id: String,
1718 recorded_len: Arc<AtomicUsize>,
1719 result: HookResult,
1720 }
1721 #[cfg(feature = "command-exec")]
1722 impl RecordingHandler {
1723 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1724 let recorded_len = Arc::new(AtomicUsize::new(0));
1725 let h = Arc::new(Self {
1726 id: id.to_string(),
1727 recorded_len: recorded_len.clone(),
1728 result,
1729 });
1730 (h, recorded_len)
1731 }
1732 }
1733 #[cfg(feature = "command-exec")]
1734 #[async_trait]
1735 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1736 fn id(&self) -> &str {
1737 &self.id
1738 }
1739 async fn handle(&self, event: &HookEvent) -> HookResult {
1740 if let Some(out) = event.tool_output.as_ref() {
1741 self.recorded_len.store(out.len(), Ordering::Relaxed);
1742 }
1743 self.result.clone()
1744 }
1745 async fn shutdown(&self) {}
1746 }
1747
1748 #[cfg(feature = "command-exec")]
1750 #[tokio::test]
1751 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1752 use crate::tools::Tool;
1753 let bus = std::sync::Arc::new(HookBus::new());
1754 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1755 bus.subscribe(
1756 HookKind::AfterToolCall,
1757 handler,
1758 None,
1759 None,
1760 perms_with(&[Permission::ToolsIntercept]),
1761 )
1762 .await
1763 .unwrap();
1764
1765 let ctx = crate::tools::ToolContext {
1766 channels: crate::tools::ToolChannels {
1767 tx_delta: None,
1768 tx_events: None,
1769 },
1770 capabilities: crate::tools::ToolCapabilities {
1771 session_allow_all: None,
1772 launch_cancel: None,
1773 memory_backend: None,
1774 telemetry: None,
1775 watcher_exit_path: None,
1776 tool_register_tx: None,
1777 session_manager: None,
1778 subagent_registry: None,
1779 event_queue: None,
1780 delegation_parent: None,
1781 codex_parent_plan: None,
1782 secret_prompt: None,
1783 orchestration: None,
1784 tool_activation: None,
1785 mcp_leases: None,
1786 extension_leases: None,
1787 memory_context: None,
1788 cwd: None,
1789 env: None,
1790 env_stripped: Vec::new(),
1791 env_warned: Default::default(),
1792 session_id: None,
1793 },
1794 limits: crate::tools::ToolLimits {
1795 max_tool_output: 30_000,
1796 max_tool_buffer: 256 * 1024,
1797 bash_timeout: 30,
1798 bash_max_timeout: 300,
1799 subagent_timeout: 300,
1800 },
1801 };
1802
1803 let bash = crate::tools::BashTool;
1804 let output = bash
1805 .execute(
1806 serde_json::json!({"command": "yes hello | head -c 150000"}),
1807 ctx,
1808 )
1809 .await
1810 .expect("bash should succeed");
1811
1812 let _ = crate::runtime::emit_after_tool_call(
1813 &bus,
1814 "bash",
1815 None,
1816 serde_json::json!({"command": "yes hello | head -c 150000"}),
1817 output,
1818 30_000,
1819 None,
1820 )
1821 .await;
1822
1823 let len = recorded_len.load(Ordering::Relaxed);
1824 assert!(
1825 len > 100_000,
1826 "after_tool_call handler was starved: received {len} bytes — \
1827 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1828 );
1829 }
1830
1831 #[tokio::test]
1835 #[serial_test::serial(synaps_base_dir)]
1836 async fn final_output_truncated_to_max_tool_output_after_hook() {
1837 let _base = crate::test_env::BaseDirGuard::new();
1839 let bus = std::sync::Arc::new(HookBus::new());
1840 let big: String = "x".repeat(100_000);
1841 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1842 bus.subscribe(
1843 HookKind::AfterToolCall,
1844 handler,
1845 None,
1846 None,
1847 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1848 )
1849 .await
1850 .unwrap();
1851
1852 let result = crate::runtime::emit_after_tool_call(
1853 &bus,
1854 "bash",
1855 None,
1856 serde_json::json!({}),
1857 "tiny original".to_string(),
1858 30_000,
1859 None,
1860 )
1861 .await;
1862
1863 assert!(
1864 result.len() <= 30_000 + 200,
1865 "post-hook output should be capped at max_tool_output (got {} bytes)",
1866 result.len()
1867 );
1868 }
1869
1870 #[tokio::test]
1873 async fn no_transform_extension_preserves_legacy_truncation() {
1874 let bus = std::sync::Arc::new(HookBus::new());
1875 let huge: String = "y".repeat(80_000);
1876
1877 let result = crate::runtime::emit_after_tool_call(
1878 &bus,
1879 "bash",
1880 None,
1881 serde_json::json!({}),
1882 huge.clone(),
1883 30_000,
1884 None,
1885 )
1886 .await;
1887
1888 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1889 assert_eq!(
1890 result, legacy,
1891 "no-extension path must be byte-identical to legacy truncation"
1892 );
1893 }
1894
1895 #[tokio::test]
1898 #[serial_test::serial(synaps_base_dir)]
1899 async fn emit_after_tool_call_truncates_char_boundary_safe() {
1900 let _base = crate::test_env::BaseDirGuard::new();
1902 let bus = std::sync::Arc::new(HookBus::new());
1904 let multibyte: String = "héllo🌟".repeat(5_000);
1905 assert!(multibyte.len() > 30_000);
1906
1907 let result = crate::runtime::emit_after_tool_call(
1908 &bus,
1909 "bash",
1910 None,
1911 serde_json::json!({}),
1912 multibyte,
1913 30_000,
1914 None,
1915 )
1916 .await;
1917 assert!(result.is_char_boundary(result.len()));
1918
1919 let bus2 = std::sync::Arc::new(HookBus::new());
1921 let big_multi = "✨".repeat(20_000);
1922 assert!(big_multi.len() > 30_000);
1923 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1924 bus2.subscribe(
1925 HookKind::AfterToolCall,
1926 handler,
1927 None,
1928 None,
1929 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1930 )
1931 .await
1932 .unwrap();
1933
1934 let result2 = crate::runtime::emit_after_tool_call(
1935 &bus2,
1936 "bash",
1937 None,
1938 serde_json::json!({}),
1939 "orig".to_string(),
1940 30_000,
1941 None,
1942 )
1943 .await;
1944 assert!(result2.is_char_boundary(result2.len()));
1945 }
1946
1947 #[tokio::test]
1948 async fn keyed_injection_isolated_per_session() {
1949 let bus = HookBus::new();
1950 bus.set_session_injection_for("s1", "one".into()).await;
1951 bus.set_session_injection_for("s2", "two".into()).await;
1952 assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
1953 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
1954 assert_eq!(bus.session_injection_for("s3").await, None);
1955 }
1956
1957 #[tokio::test]
1958 async fn clear_removes_only_that_session() {
1959 let bus = HookBus::new();
1960 bus.set_session_injection_for("s1", "one".into()).await;
1961 bus.set_session_injection_for("s2", "two".into()).await;
1962 bus.clear_session_injection("s1").await;
1963 assert_eq!(bus.session_injection_for("s1").await, None);
1964 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
1965 }
1966
1967 #[tokio::test]
1968 #[allow(deprecated)]
1969 async fn deprecated_shims_use_empty_key() {
1970 let bus = HookBus::new();
1971 bus.set_session_injection("legacy".into()).await;
1972 assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
1973 assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
1974 assert_eq!(bus.session_injection_for("s1").await, None);
1975 }
1976}