Skip to main content

agent_engine/extensions/hooks/
mod.rs

1//! HookBus — the central dispatcher for extension hooks.
2//!
3//! The HookBus holds registered handlers and dispatches typed events to them.
4//! Without any handlers, `emit()` is a no-op fast path (<1µs).
5//!
6//! Tool-specific hooks filter by tool name before dispatching.
7
8pub 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
21/// Default timeout for a single hook handler call.
22const 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/// A registered hook handler with its metadata.
45#[derive(Clone)]
46pub struct HandlerRegistration {
47    /// The extension handler.
48    pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
49    /// Optional tool name filter (None = all tools).
50    pub tool_filter: Option<String>,
51    /// Optional matcher for event payloads.
52    pub matcher: Option<HookMatcher>,
53    /// Permissions granted to this handler's extension.
54    pub permissions: PermissionSet,
55}
56
57/// The central hook dispatcher.
58///
59/// Thread-safe: uses `RwLock` so multiple concurrent emitters can read
60/// the handler list, and registration takes a write lock only briefly.
61pub struct HookBus {
62    handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
63    /// Context injected by `on_session_start` handlers, held for the lifetime
64    /// of the session.
65    ///
66    /// `before_message` injection is per-turn and re-applied on every request.
67    /// Session-start injection is the once-per-session equivalent: an
68    /// extension that wants to hand the model a session handoff should not
69    /// have to fake it by tracking "have I injected yet?" across every
70    /// message, and should not pay to re-send it each turn.
71    ///
72    /// Keyed by conversation session id: one process may host N sessions
73    /// (Phase 2 daemon), and each must read only its own injection.
74    session_injection: RwLock<HashMap<String, String>>,
75}
76
77impl HookBus {
78    /// Create an empty HookBus with no handlers.
79    pub fn new() -> Self {
80        Self {
81            handlers: RwLock::new(HashMap::new()),
82            session_injection: RwLock::new(HashMap::new()),
83        }
84    }
85
86    /// Record context injected by an `on_session_start` handler for
87    /// `session_id`.
88    ///
89    /// Called once per session, by whoever emits the session-start event,
90    /// after extensions have loaded. Later calls for the same id replace the
91    /// stored value.
92    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    /// Context injected at session start for `session_id`, if any. Read on
100    /// every turn while composing the system prompt.
101    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    /// Drop the injection recorded for `session_id` (session teardown).
106    pub async fn clear_session_injection(&self, session_id: &str) {
107        self.session_injection.write().await.remove(session_id);
108    }
109
110    /// Compatibility shim: writes under the empty key.
111    #[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    /// Compatibility shim: reads the empty key.
117    #[deprecated(note = "use session_injection_for")]
118    pub async fn session_injection(&self) -> Option<String> {
119        self.session_injection_for("").await
120    }
121
122    /// Register a handler for a specific hook kind.
123    ///
124    /// Returns an error if the handler's permissions don't allow
125    /// subscribing to this hook kind.
126    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        // Permission check
135        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    /// Emit a hook event to all registered handlers.
157    ///
158    /// Returns the first `Block` result if any handler blocks, otherwise
159    /// returns `Continue`. Handlers are called in registration order.
160    ///
161    /// If no handlers are registered for this hook, returns immediately
162    /// (the no-extensions fast path).
163    pub async fn emit(&self, event: &HookEvent) -> HookResult {
164        // Snapshot the handler list and drop the lock immediately.
165        // This prevents holding the RwLock across async handler calls
166        // (which could block subscribe/unsubscribe for the entire
167        // duration of IPC round-trips to extension processes).
168        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, // fast path: no handlers
173            }
174        }; // lock dropped here
175
176        // Collect injections from all handlers rather than returning on first
177        let mut injections: Vec<String> = Vec::new();
178
179        for reg in &registrations {
180            // Tool-specific filter: skip handlers that don't match.
181            // Check both API name and runtime name so MCP tools with
182            // sanitized names (slashes→underscores) still match.
183            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            // Call handler with timeout
202            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                    // Accumulate — don't early-return. Multiple extensions can inject.
273                    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                    // Two-key gate: subscribing to after_tool_call needs
285                    // `tools.intercept` (observe); rewriting the output
286                    // additionally needs `tools.transform_output`. Without it,
287                    // ignore this handler's Replace (fail-safe: original output
288                    // preserved) and continue the chain.
289                    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                    // Persistent audit trail — who rewrote which tool's output
306                    // and when, plus sizes (never content). Best-effort: a
307                    // failed audit write must not break the tool flow.
308                    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                    // First transform wins (mirrors Modify). Chaining is a future enhancement.
316                    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                    // Fail-open: timeout = continue
334                }
335            }
336        }
337
338        // Merge accumulated injections from all handlers
339        if !injections.is_empty() {
340            HookResult::Inject {
341                content: injections.join("\n\n"),
342            }
343        } else {
344            HookResult::Continue
345        }
346    }
347
348    /// Emit a hook event to all registered handlers **concurrently**.
349    ///
350    /// All handlers race under a single shared timeout (`per_handler_timeout`).
351    /// Results are collected and the first `Block` wins; injections are merged.
352    ///
353    /// **When to use this over `emit()`:**
354    ///
355    /// Only safe for hook kinds whose handlers are order-independent — i.e.
356    /// where no handler's result depends on another's execution.  Currently
357    /// that applies to:
358    ///   - `on_session_end`: only `Continue` is a valid result; handlers are
359    ///     fire-and-forget notification calls (deck, d20, jawz-widget,
360    ///     synaps-tasks all write to their own stores independently).
361    ///
362    /// **Do NOT use for** `before_tool_call` / `after_tool_call` / `before_message`
363    /// hooks where `Block` / `Modify` / `Replace` / `Inject` semantics require a
364    /// defined winner when two handlers disagree — `join_all` completion order is
365    /// nondeterministic, so the "winner" would be unstable. Those hooks must use
366    /// the sequential [`emit`](Self::emit), which enforces first-wins ordering.
367    ///
368    /// With N extensions and a 5 s per-handler timeout, serial emit takes up
369    /// to N×5 s; concurrent emit collapses that to a single 5 s window
370    /// regardless of N — critical for teardown budgets.
371    pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
372        // H4: real assert, not debug_assert — this invariant protects against
373        // nondeterministic Block/Modify/Replace results in release builds.
374        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        // Snapshot handler list (same as emit()).
385        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, // fast path: no handlers
390            }
391        };
392
393        // Dispatch all handlers simultaneously.
394        let futures: Vec<_> =
395            registrations
396                .iter()
397                .filter(|reg| {
398                    // Apply tool filter before spawning.
399                    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    /// Remove all handlers for a given extension ID.
461    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    /// Number of registered handlers across all hooks.
469    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    /// Check if any handlers are registered (for fast-path decisions).
475    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    /// Return all (kind, tool_filter) pairs subscribed by the given extension id.
481    /// Sorted by kind name, then by tool_filter (None first), for stable output.
482    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    /// Test handler that counts calls and returns a configurable result.
512    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    /// emit_after_tool_call returns the substituted output when an
555    /// after_tool_call handler returns Replace — this is the seam that lets an
556    /// extension compress/redact tool output before it enters history.
557    #[tokio::test]
558    #[serial_test::serial(synaps_base_dir)]
559    async fn after_tool_call_replace_substitutes_recorded_output() {
560        // #186: Replace writes the intercept audit — isolate the base dir.
561        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    /// With no transform handler, the original output is preserved unchanged.
599    #[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    /// First Replace wins: once an earlier after_tool_call handler returns
617    /// Replace, later handlers are never reached (mirrors block/modify
618    /// chain-stop). Proves the "first transform wins" comment in emit().
619    #[tokio::test]
620    #[serial_test::serial(synaps_base_dir)]
621    async fn replace_stops_chain_for_after_tool_call() {
622        // #186: Replace writes the intercept audit — isolate the base dir.
623        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); // never reached — first transform wins
663    }
664
665    /// Two-key gate: an extension may subscribe to after_tool_call with only
666    /// `tools.intercept` (observe), but its Replace is IGNORED without the
667    /// additional `tools.transform_output` — the original output is preserved.
668    #[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        // Only the observe key — NOT tools.transform_output.
678        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); // never reached
903    }
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); // never reached
943    }
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        // Should NOT fire for 'read' tool
962        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        // SHOULD fire for 'bash' tool
967        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(); // empty — no permissions
977
978        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        // sorted by kind name then by tool_filter (None first)
1033        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    // ────────────────────────────────────────────────────────────────────────
1059    // compress-then-truncate ordering (Synaps Fork 1) — RED stub
1060    // ────────────────────────────────────────────────────────────────────────
1061
1062    #[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    /// Test A (RED stub, current signature) — proves the bug.
1094    #[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    /// Test B: when a transform Replaces with a >max_tool_output string, the
1173    /// FINAL emit_after_tool_call return is ≤ max_tool_output (+marker overhead).
1174    /// Proves compress-then-truncate ordering at the new site.
1175    #[tokio::test]
1176    #[serial_test::serial(synaps_base_dir)]
1177    async fn final_output_truncated_to_max_tool_output_after_hook() {
1178        // #186: Replace writes the intercept audit — isolate the base dir.
1179        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    /// Test C: with NO transform, the final output is byte-identical to the
1212    /// legacy truncate_tool_result(...) result — behavior preservation invariant.
1213    #[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    /// Test D: emit_after_tool_call unit-level — Continue and Replace branches
1237    /// both truncate to max_tool_output, and truncation is char-boundary safe.
1238    #[tokio::test]
1239    #[serial_test::serial(synaps_base_dir)]
1240    async fn emit_after_tool_call_truncates_char_boundary_safe() {
1241        // #186: Replace writes the intercept audit — isolate the base dir.
1242        let _base = crate::test_env::BaseDirGuard::new();
1243        // Continue path with multibyte content.
1244        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        // Replace path with multibyte content.
1261        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}