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        HookResult::ContextPhase { .. } => "context_phase",
42    }
43}
44
45/// Two-key gate for an advisory `context_phase` report: subscribing to
46/// `on_message_complete` needs `privacy.llm_content`; reporting a phase to
47/// the host's context management additionally needs `session.lifecycle`.
48/// Returns `Some(phase)` when the report is accepted, `None` (after a
49/// warning) when it must be ignored and treated as `continue`.
50fn 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/// A registered hook handler with its metadata.
73#[derive(Clone)]
74pub struct HandlerRegistration {
75    /// The extension handler.
76    pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
77    /// Optional tool name filter (None = all tools).
78    pub tool_filter: Option<String>,
79    /// Optional matcher for event payloads.
80    pub matcher: Option<HookMatcher>,
81    /// Permissions granted to this handler's extension.
82    pub permissions: PermissionSet,
83}
84
85/// The central hook dispatcher.
86///
87/// Thread-safe: uses `RwLock` so multiple concurrent emitters can read
88/// the handler list, and registration takes a write lock only briefly.
89pub struct HookBus {
90    handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
91    /// Per-extension-id load generation, bumped by [`Self::unsubscribe_all`]
92    /// (unload / reload / reinstall). Stamped on every `Confirm` so the
93    /// "Allow all" latch keys on (id, generation): a different plugin later
94    /// installed under the same id does not inherit an earlier consent.
95    generations: std::sync::Mutex<HashMap<String, u64>>,
96    /// Context injected by `on_session_start` handlers, held for the lifetime
97    /// of the session.
98    ///
99    /// `before_message` injection is per-turn and re-applied on every request.
100    /// Session-start injection is the once-per-session equivalent: an
101    /// extension that wants to hand the model a session handoff should not
102    /// have to fake it by tracking "have I injected yet?" across every
103    /// message, and should not pay to re-send it each turn.
104    ///
105    /// Keyed by conversation session id: one process may host N sessions
106    /// (Phase 2 daemon), and each must read only its own injection.
107    session_injection: RwLock<HashMap<String, String>>,
108}
109
110impl HookBus {
111    /// Create an empty HookBus with no handlers.
112    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    /// Current load generation of `extension_id` (0 until first unloaded).
121    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    /// Record context injected by an `on_session_start` handler for
131    /// `session_id`.
132    ///
133    /// Called once per session, by whoever emits the session-start event,
134    /// after extensions have loaded. Later calls for the same id replace the
135    /// stored value.
136    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    /// Context injected at session start for `session_id`, if any. Read on
144    /// every turn while composing the system prompt.
145    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    /// Drop the injection recorded for `session_id` (session teardown).
150    pub async fn clear_session_injection(&self, session_id: &str) {
151        self.session_injection.write().await.remove(session_id);
152    }
153
154    /// Compatibility shim: writes under the empty key.
155    #[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    /// Compatibility shim: reads the empty key.
161    #[deprecated(note = "use session_injection_for")]
162    pub async fn session_injection(&self) -> Option<String> {
163        self.session_injection_for("").await
164    }
165
166    /// Register a handler for a specific hook kind.
167    ///
168    /// Returns an error if the handler's permissions don't allow
169    /// subscribing to this hook kind.
170    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        // Permission check
179        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    /// Emit a hook event to all registered handlers.
201    ///
202    /// Returns the first `Block` result if any handler blocks, otherwise
203    /// returns `Continue`. Handlers are called in registration order.
204    ///
205    /// For `before_tool_call` only `Block` ends the chain (#148 I1):
206    /// - `Modify` rewrites the input and the chain continues; every later
207    ///   handler sees (and vets) the modified input, and the final input is
208    ///   returned as `Modify` unless something blocks or confirms.
209    /// - `Confirm` is recorded and the chain continues, so a later `Block`
210    ///   wins even when the confirming extension is already latched.
211    ///   Confirms are merged (all sources, all messages) and returned last,
212    ///   carrying any input modified BEFORE the first Confirm.
213    /// - A `Modify` after a pending `Confirm` is ignored (logged at warn):
214    ///   the user confirms the input as it stood when they were asked.
215    ///
216    /// If no handlers are registered for this hook, returns immediately
217    /// (the no-extensions fast path).
218    pub async fn emit(&self, event: &HookEvent) -> HookResult {
219        // Snapshot the handler list and drop the lock immediately.
220        // This prevents holding the RwLock across async handler calls
221        // (which could block subscribe/unsubscribe for the entire
222        // duration of IPC round-trips to extension processes).
223        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, // fast path: no handlers
228            }
229        }; // lock dropped here
230
231        // Collect injections from all handlers rather than returning on first
232        let mut injections: Vec<String> = Vec::new();
233        // First valid advisory context-phase report wins; the chain continues
234        // so later observers still see the event.
235        let mut context_phase: Option<String> = None;
236        // (source extension id, message) for every Confirm, in chain order.
237        let mut confirms: Vec<(String, String)> = Vec::new();
238        // (id, load generation) per distinct confirming extension.
239        let mut source_keys: Vec<(String, u64)> = Vec::new();
240
241        // Input rewritten by an earlier Modify (#148 I1 / prA I1). Later
242        // handlers see — and vet — the MODIFIED input, so a Modify can no
243        // longer smuggle unchecked input past a later Block or Confirm.
244        let mut modified_event: Option<HookEvent> = None;
245        // Who rewrote it (chain order, de-duplicated).
246        let mut modified_by: Vec<String> = Vec::new();
247
248        for reg in &registrations {
249            let ev: &HookEvent = modified_event.as_ref().unwrap_or(event);
250            // Tool-specific filter: skip handlers that don't match.
251            // Check both API name and runtime name so MCP tools with
252            // sanitized names (slashes→underscores) still match.
253            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            // Call handler with timeout
272            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                    // Accumulate — don't early-return. Multiple extensions can inject.
343                    injections.push(content);
344                }
345                Ok(HookResult::Modify { input }) => {
346                    if !confirms.is_empty() {
347                        // The user is asked about the input as it stood at
348                        // the first Confirm; a rewrite after that point
349                        // would run something they never saw.
350                        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                    // Two-key gate: subscribing to after_tool_call needs
373                    // `tools.intercept` (observe); rewriting the output
374                    // additionally needs `tools.transform_output`. Without it,
375                    // ignore this handler's Replace (fail-safe: original output
376                    // preserved) and continue the chain.
377                    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                    // Persistent audit trail — who rewrote which tool's output
394                    // and when, plus sizes (never content). Best-effort: a
395                    // failed audit write must not break the tool flow.
396                    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                    // First transform wins (mirrors Modify). Chaining is a future enhancement.
404                    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                    // Host-stamped attribution: the "Allow all" latch and
413                    // the dialog key on the extension that actually asked.
414                    // Keep going: a later Block must still win.
415                    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                    // Advisory only — never stops the chain. Two-key gated;
423                    // an ignored report is a plain continue.
424                    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                    // Fail-open: timeout = continue
444                }
445            }
446        }
447
448        // No Block anywhere in the chain: resolve the Confirm(s) last.
449        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        // Merge accumulated injections from all handlers. An advisory phase
479        // report is returned only when nothing else would be (Block/Inject
480        // take precedence; on_message_complete permits neither).
481        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    /// Emit a hook event to all registered handlers **concurrently**.
493    ///
494    /// All handlers race under a single shared timeout (`per_handler_timeout`).
495    /// Results are collected and the first `Block` wins; injections are merged.
496    ///
497    /// **When to use this over `emit()`:**
498    ///
499    /// Only safe for hook kinds whose handlers are order-independent — i.e.
500    /// where no handler's result depends on another's execution.  Currently
501    /// that applies to:
502    ///   - `on_session_end`: only `Continue` is a valid result; handlers are
503    ///     fire-and-forget notification calls (deck, d20, jawz-widget,
504    ///     synaps-tasks all write to their own stores independently).
505    ///
506    /// **Do NOT use for** `before_tool_call` / `after_tool_call` / `before_message`
507    /// hooks where `Block` / `Modify` / `Replace` / `Inject` semantics require a
508    /// defined winner when two handlers disagree — `join_all` completion order is
509    /// nondeterministic, so the "winner" would be unstable. Those hooks must use
510    /// the sequential [`emit`](Self::emit), which enforces first-wins ordering.
511    ///
512    /// With N extensions and a 5 s per-handler timeout, serial emit takes up
513    /// to N×5 s; concurrent emit collapses that to a single 5 s window
514    /// regardless of N — critical for teardown budgets.
515    pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
516        // H4: real assert, not debug_assert — this invariant protects against
517        // nondeterministic Block/Modify/Replace results in release builds.
518        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        // Snapshot handler list (same as emit()).
529        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, // fast path: no handlers
534            }
535        };
536
537        // Dispatch all handlers simultaneously.
538        let futures: Vec<_> =
539            registrations
540                .iter()
541                .filter(|reg| {
542                    // Apply tool filter before spawning.
543                    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        // First accepted report in completion order (registration order for
572        // the collected results); advisory only, never stops the chain.
573        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    /// Remove all handlers for a given extension ID.
638    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    /// Number of registered handlers across all hooks.
652    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    /// Check if any handlers are registered (for fast-path decisions).
658    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    /// Return all (kind, tool_filter) pairs subscribed by the given extension id.
664    /// Sorted by kind name, then by tool_filter (None first), for stable output.
665    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    /// Test handler that counts calls and returns a configurable result.
695    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    /// emit_after_tool_call returns the substituted output when an
738    /// after_tool_call handler returns Replace — this is the seam that lets an
739    /// extension compress/redact tool output before it enters history.
740    #[tokio::test]
741    #[serial_test::serial(synaps_base_dir)]
742    async fn after_tool_call_replace_substitutes_recorded_output() {
743        // #186: Replace writes the intercept audit — isolate the base dir.
744        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    /// With no transform handler, the original output is preserved unchanged.
782    #[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    /// First Replace wins: once an earlier after_tool_call handler returns
800    /// Replace, later handlers are never reached (mirrors block/modify
801    /// chain-stop). Proves the "first transform wins" comment in emit().
802    #[tokio::test]
803    #[serial_test::serial(synaps_base_dir)]
804    async fn replace_stops_chain_for_after_tool_call() {
805        // #186: Replace writes the intercept audit — isolate the base dir.
806        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); // never reached — first transform wins
846    }
847
848    /// Two-key gate: an extension may subscribe to after_tool_call with only
849    /// `tools.intercept` (observe), but its Replace is IGNORED without the
850    /// additional `tools.transform_output` — the original output is preserved.
851    #[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        // Only the observe key — NOT tools.transform_output.
861        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    // ── context_phase (advisory work-phase report) ───────────────────────────
890
891    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    /// Two-key gate, positive side: `privacy.llm_content` (subscribe) plus
903    /// `session.lifecycle` (report) => the advisory report is returned.
904    #[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    /// Two-key gate, negative side: the observe key alone lets the handler
924    /// run, but its report is ignored (treated as continue).
925    #[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    /// A report does not stop the chain: later observers still run, and the
945    /// first VALID report wins over later ones (an unprivileged earlier
946    /// report does not claim the slot).
947    #[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    /// A Block from an on_message_complete handler is still ignored, and a
989    /// valid phase report from another handler is still returned.
990    #[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    /// context_phase is not allowed on any other hook kind, even with both
1026    /// permissions — sequential and concurrent paths alike.
1027    #[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        // Concurrent path (only legal for non-transform hooks).
1078        let end = HookEvent::on_session_end("s", None);
1079        assert_eq!(bus.emit_concurrent(&end).await, HookResult::Continue);
1080    }
1081
1082    /// The concurrent emit path applies the same two-key gate and first-wins
1083    /// aggregation for on_message_complete.
1084    #[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    /// #148 I1: a Confirm no longer stops the chain. Later handlers run, and
1229    /// with nothing else to say the Confirm is returned (resolved last).
1230    #[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    /// A later extension's Block wins over an earlier Confirm.
1245    #[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    /// Two extensions confirming the same call: one prompt carrying both
1263    /// sources (so "Allow all" from one cannot silently skip the other).
1264    #[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    /// With the confirming extension latched ("Allow all"), a later
1283    /// extension's Block is still evaluated and still blocks.
1284    #[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    /// Records the `tool_input` it was shown, then returns `result`.
1313    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    /// prA I1: a Modify no longer ends the chain. A later Block sees the
1353    /// MODIFIED input and still wins.
1354    #[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    /// Modify then Confirm: the user is still asked, and approval runs the
1373    /// modified input.
1374    #[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        // Approved (here: auto-approve) → the MODIFIED input runs.
1399        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    /// Two modifiers compose in order; with nothing else, the final input is
1415    /// returned as a Modify.
1416    #[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    /// Unloading / reinstalling an extension under the same id bumps its
1432    /// load generation, so a latch armed for the old instance doesn't match.
1433    #[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    /// A Modify after a pending Confirm is not applied (the user is asked to
1471    /// confirm the original call) — same outcome as before, when the chain
1472    /// stopped at the Confirm.
1473    #[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); // never reached
1571    }
1572
1573    /// prA I1 (was `modify_stops_chain`, which pinned the bypass): a later
1574    /// Block still runs after a Modify, and wins.
1575    #[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        // Should NOT fire for 'read' tool
1614        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        // SHOULD fire for 'bash' tool
1619        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(); // empty — no permissions
1629
1630        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        // sorted by kind name then by tool_filter (None first)
1685        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    // ────────────────────────────────────────────────────────────────────────
1711    // compress-then-truncate ordering (Synaps Fork 1) — RED stub
1712    // ────────────────────────────────────────────────────────────────────────
1713
1714    #[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    /// Test A (RED stub, current signature) — proves the bug.
1749    #[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    /// Test B: when a transform Replaces with a >max_tool_output string, the
1832    /// FINAL emit_after_tool_call return is ≤ max_tool_output (+marker overhead).
1833    /// Proves compress-then-truncate ordering at the new site.
1834    #[tokio::test]
1835    #[serial_test::serial(synaps_base_dir)]
1836    async fn final_output_truncated_to_max_tool_output_after_hook() {
1837        // #186: Replace writes the intercept audit — isolate the base dir.
1838        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    /// Test C: with NO transform, the final output is byte-identical to the
1871    /// legacy truncate_tool_result(...) result — behavior preservation invariant.
1872    #[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    /// Test D: emit_after_tool_call unit-level — Continue and Replace branches
1896    /// both truncate to max_tool_output, and truncation is char-boundary safe.
1897    #[tokio::test]
1898    #[serial_test::serial(synaps_base_dir)]
1899    async fn emit_after_tool_call_truncates_char_boundary_safe() {
1900        // #186: Replace writes the intercept audit — isolate the base dir.
1901        let _base = crate::test_env::BaseDirGuard::new();
1902        // Continue path with multibyte content.
1903        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        // Replace path with multibyte content.
1920        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}