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/// Whether a handler's `tool` filter covers `ev`. A filter matches the
73/// tool's API name or runtime name (MCP tools with sanitized names still
74/// match). Before a call, a filter for `edit` or `write` also covers the
75/// builtin `multi_edit`, which changes files the same way: a guard written
76/// before `multi_edit` existed must still block or confirm it. Output
77/// transforms (`after_tool_call`) stay exact; their rewrite is per-format.
78fn tool_filter_matches(filter: &str, ev: &HookEvent) -> bool {
79    let (api, runtime) = (ev.tool_name.as_deref(), ev.tool_runtime_name.as_deref());
80    if api == Some(filter) || runtime == Some(filter) {
81        return true;
82    }
83    ev.kind == HookKind::BeforeToolCall
84        && matches!(filter, "edit" | "write")
85        && runtime.or(api) == Some("multi_edit")
86}
87
88/// A registered hook handler with its metadata.
89#[derive(Clone)]
90pub struct HandlerRegistration {
91    /// The extension handler.
92    pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
93    /// Optional tool name filter (None = all tools).
94    pub tool_filter: Option<String>,
95    /// Optional matcher for event payloads.
96    pub matcher: Option<HookMatcher>,
97    /// Permissions granted to this handler's extension.
98    pub permissions: PermissionSet,
99}
100
101/// The central hook dispatcher.
102///
103/// Thread-safe: uses `RwLock` so multiple concurrent emitters can read
104/// the handler list, and registration takes a write lock only briefly.
105pub struct HookBus {
106    handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
107    /// Per-extension-id load generation, bumped by [`Self::unsubscribe_all`]
108    /// (unload / reload / reinstall). Stamped on every `Confirm` so the
109    /// "Allow all" latch keys on (id, generation): a different plugin later
110    /// installed under the same id does not inherit an earlier consent.
111    generations: std::sync::Mutex<HashMap<String, u64>>,
112    /// Context injected by `on_session_start` handlers, held for the lifetime
113    /// of the session.
114    ///
115    /// `before_message` injection is per-turn and re-applied on every request.
116    /// Session-start injection is the once-per-session equivalent: an
117    /// extension that wants to hand the model a session handoff should not
118    /// have to fake it by tracking "have I injected yet?" across every
119    /// message, and should not pay to re-send it each turn.
120    ///
121    /// Keyed by conversation session id: one process may host N sessions
122    /// (Phase 2 daemon), and each must read only its own injection.
123    session_injection: RwLock<HashMap<String, String>>,
124    /// `Some(host)`: this bus is a subagent worker's view of `host`
125    /// ([`Self::worker_transforms`]) and has no handlers of its own.
126    transforms_of: Option<Arc<HookBus>>,
127}
128
129impl HookBus {
130    /// Create an empty HookBus with no handlers.
131    pub fn new() -> Self {
132        Self {
133            handlers: RwLock::new(HashMap::new()),
134            session_injection: RwLock::new(HashMap::new()),
135            generations: std::sync::Mutex::new(HashMap::new()),
136            transforms_of: None,
137        }
138    }
139
140    /// The hook bus a subagent worker runs with: a read-only view of
141    /// `host`'s `after_tool_call` subscriptions from extensions that hold
142    /// `tools.transform_output`, so output transforms (compression,
143    /// redaction) see worker tool results too. Nothing else fires in a
144    /// worker: no `before_tool_call` (no confirm prompt, block or modify),
145    /// no message or session hooks, no observe-only `after_tool_call`.
146    ///
147    /// Live, not a snapshot: every [`Self::emit`] reads `host`'s current
148    /// handler list, so an extension loaded, reloaded or unloaded while a
149    /// worker runs applies from that worker's next tool call (a snapshot
150    /// would keep calling a reloaded extension's stopped instance).
151    /// Dispatch is otherwise [`Self::emit`]'s: chain order, the permission
152    /// matrix, the 5 s timeout, first `replace` wins. Handlers are called
153    /// through [`ExtensionHandler::handle_no_spawn`]: a worker never starts
154    /// or restarts an extension process, and one that is not running
155    /// passes the output through.
156    ///
157    /// [`ExtensionHandler::handle_no_spawn`]: crate::extensions::runtime::ExtensionHandler::handle_no_spawn
158    pub fn worker_transforms(host: &Arc<HookBus>) -> Arc<HookBus> {
159        // A view of a view reads the same host.
160        let host = host.transforms_of.clone().unwrap_or_else(|| Arc::clone(host));
161        Arc::new(Self {
162            transforms_of: Some(host),
163            ..Self::new()
164        })
165    }
166
167    /// Whether this is a worker's view ([`Self::worker_transforms`]).
168    pub fn is_worker_view(&self) -> bool {
169        self.transforms_of.is_some()
170    }
171
172    /// Snapshot of what a worker view dispatches for `kind`: `None` for any
173    /// kind but `after_tool_call`, or when no handler holds
174    /// `tools.transform_output`.
175    async fn worker_transform_registrations(
176        &self,
177        kind: HookKind,
178    ) -> Option<Vec<HandlerRegistration>> {
179        if kind != HookKind::AfterToolCall {
180            return None;
181        }
182        let handlers = self.handlers.read().await;
183        let regs: Vec<HandlerRegistration> = handlers
184            .get(&kind)?
185            .iter()
186            .filter(|reg| reg.permissions.has(Permission::ToolsTransformOutput))
187            .cloned()
188            .collect();
189        (!regs.is_empty()).then_some(regs)
190    }
191
192    /// Current load generation of `extension_id` (0 until first unloaded).
193    pub fn extension_generation(&self, extension_id: &str) -> u64 {
194        self.generations
195            .lock()
196            .unwrap_or_else(|e| e.into_inner())
197            .get(extension_id)
198            .copied()
199            .unwrap_or(0)
200    }
201
202    /// Record context injected by an `on_session_start` handler for
203    /// `session_id`.
204    ///
205    /// Called once per session, by whoever emits the session-start event,
206    /// after extensions have loaded. Later calls for the same id replace the
207    /// stored value.
208    pub async fn set_session_injection_for(&self, session_id: &str, content: String) {
209        self.session_injection
210            .write()
211            .await
212            .insert(session_id.to_string(), content);
213    }
214
215    /// Context injected at session start for `session_id`, if any. Read on
216    /// every turn while composing the system prompt.
217    pub async fn session_injection_for(&self, session_id: &str) -> Option<String> {
218        self.session_injection.read().await.get(session_id).cloned()
219    }
220
221    /// Drop the injection recorded for `session_id` (session teardown).
222    pub async fn clear_session_injection(&self, session_id: &str) {
223        self.session_injection.write().await.remove(session_id);
224    }
225
226    /// Compatibility shim: writes under the empty key.
227    #[deprecated(note = "use set_session_injection_for")]
228    pub async fn set_session_injection(&self, content: String) {
229        self.set_session_injection_for("", content).await;
230    }
231
232    /// Compatibility shim: reads the empty key.
233    #[deprecated(note = "use session_injection_for")]
234    pub async fn session_injection(&self) -> Option<String> {
235        self.session_injection_for("").await
236    }
237
238    /// Register a handler for a specific hook kind.
239    ///
240    /// Returns an error if the handler's permissions don't allow
241    /// subscribing to this hook kind.
242    pub async fn subscribe(
243        &self,
244        kind: HookKind,
245        handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
246        tool_filter: Option<String>,
247        matcher: Option<HookMatcher>,
248        permissions: PermissionSet,
249    ) -> Result<(), String> {
250        if self.is_worker_view() {
251            return Err(format!(
252                "Extension '{}' cannot subscribe to a worker's hook view; subscribe to the host bus",
253                handler.id(),
254            ));
255        }
256        // Permission check
257        if !permissions.allows_hook(kind) {
258            return Err(format!(
259                "Extension '{}' lacks permission '{}' required for hook '{}'",
260                handler.id(),
261                kind.required_permission().as_str(),
262                kind.as_str(),
263            ));
264        }
265
266        let reg = HandlerRegistration {
267            handler,
268            tool_filter,
269            matcher,
270            permissions,
271        };
272
273        let mut handlers = self.handlers.write().await;
274        handlers.entry(kind).or_default().push(reg);
275        Ok(())
276    }
277
278    /// Emit a hook event to all registered handlers.
279    ///
280    /// Returns the first `Block` result if any handler blocks, otherwise
281    /// returns `Continue`. Handlers are called in registration order.
282    ///
283    /// For `before_tool_call` only `Block` ends the chain (#148 I1):
284    /// - `Modify` rewrites the input and the chain continues; every later
285    ///   handler sees (and vets) the modified input, and the final input is
286    ///   returned as `Modify` unless something blocks or confirms.
287    /// - `Confirm` is recorded and the chain continues, so a later `Block`
288    ///   wins even when the confirming extension is already latched.
289    ///   Confirms are merged (all sources, all messages) and returned last,
290    ///   carrying any input modified BEFORE the first Confirm.
291    /// - A `Modify` after a pending `Confirm` is ignored (logged at warn):
292    ///   the user confirms the input as it stood when they were asked.
293    ///
294    /// If no handlers are registered for this hook, returns immediately
295    /// (the no-extensions fast path).
296    ///
297    /// On a worker view ([`Self::worker_transforms`]) only the host's
298    /// `after_tool_call` transform handlers run, each through
299    /// `handle_no_spawn`; every other kind returns `Continue` at once.
300    pub async fn emit(&self, event: &HookEvent) -> HookResult {
301        // Snapshot the handler list and drop the lock immediately.
302        // This prevents holding the RwLock across async handler calls
303        // (which could block subscribe/unsubscribe for the entire
304        // duration of IPC round-trips to extension processes).
305        let no_spawn = self.is_worker_view();
306        let registrations = match &self.transforms_of {
307            Some(host) => match host.worker_transform_registrations(event.kind).await {
308                Some(regs) => regs,
309                None => return HookResult::Continue,
310            },
311            None => {
312                let handlers = self.handlers.read().await;
313                match handlers.get(&event.kind) {
314                    Some(regs) if !regs.is_empty() => regs.clone(),
315                    _ => return HookResult::Continue, // fast path: no handlers
316                }
317            }
318        }; // lock dropped here
319
320        // Collect injections from all handlers rather than returning on first
321        let mut injections: Vec<String> = Vec::new();
322        // First valid advisory context-phase report wins; the chain continues
323        // so later observers still see the event.
324        let mut context_phase: Option<String> = None;
325        // (source extension id, message) for every Confirm, in chain order.
326        let mut confirms: Vec<(String, String)> = Vec::new();
327        // (id, load generation) per distinct confirming extension.
328        let mut source_keys: Vec<(String, u64)> = Vec::new();
329
330        // Input rewritten by an earlier Modify (#148 I1 / prA I1). Later
331        // handlers see — and vet — the MODIFIED input, so a Modify can no
332        // longer smuggle unchecked input past a later Block or Confirm.
333        let mut modified_event: Option<HookEvent> = None;
334        // Who rewrote it (chain order, de-duplicated).
335        let mut modified_by: Vec<String> = Vec::new();
336
337        for reg in &registrations {
338            let ev: &HookEvent = modified_event.as_ref().unwrap_or(event);
339            // Tool-specific filter: skip handlers that don't match.
340            // Check both API name and runtime name so MCP tools with
341            // sanitized names (slashes→underscores) still match.
342            if let Some(ref filter) = reg.tool_filter {
343                if !tool_filter_matches(filter, ev) {
344                    continue;
345                }
346            }
347
348            if let Some(ref matcher) = reg.matcher {
349                if !matcher.matches(ev) {
350                    continue;
351                }
352            }
353
354            // Call handler with timeout
355            let handler = reg.handler.clone();
356            let event_clone = ev.clone();
357            let trace_enabled = extensions_trace_enabled();
358            let started_at = trace_enabled.then(Instant::now);
359            let call = async {
360                if no_spawn {
361                    handler.handle_no_spawn(&event_clone).await
362                } else {
363                    handler.handle(&event_clone).await
364                }
365            };
366            let result = tokio::time::timeout(HANDLER_TIMEOUT, call).await;
367
368            if trace_enabled {
369                let health = reg.handler.health().await;
370                let health = health.as_str();
371                let restart_count = reg.handler.restart_count().await;
372                let duration_ms = started_at
373                    .map(|start| start.elapsed().as_millis() as u64)
374                    .unwrap_or(0);
375                match &result {
376                    Ok(hook_result) => {
377                        let action = hook_result_action(hook_result);
378                        tracing::info!(
379                            extension_trace = true,
380                            hook = %ev.kind.as_str(),
381                            extension = %reg.handler.id(),
382                            action = action,
383                            duration_ms = duration_ms,
384                            health = health,
385                            restart_count = restart_count,
386                            "Extension hook trace"
387                        );
388                    }
389                    Err(_) => {
390                        tracing::warn!(
391                            extension_trace = true,
392                            hook = %ev.kind.as_str(),
393                            extension = %reg.handler.id(),
394                            action = "timeout",
395                            duration_ms = duration_ms,
396                            timeout_secs = HANDLER_TIMEOUT.as_secs(),
397                            health = health,
398                            restart_count = restart_count,
399                            "Extension hook trace"
400                        );
401                    }
402                }
403            }
404
405            match result {
406                Ok(result) if !ev.kind.allows_result(&result) => {
407                    tracing::warn!(
408                        hook = %ev.kind.as_str(),
409                        extension = %reg.handler.id(),
410                        action = hook_result_action(&result),
411                        "Extension returned action not allowed for hook — ignoring"
412                    );
413                    continue;
414                }
415                Ok(HookResult::Block { reason }) => {
416                    tracing::info!(
417                        hook = %ev.kind.as_str(),
418                        extension = %reg.handler.id(),
419                        reason = %reason,
420                        "Hook blocked by extension"
421                    );
422                    return HookResult::Block { reason };
423                }
424                Ok(HookResult::Continue) => {}
425                Ok(HookResult::Inject { content }) => {
426                    tracing::debug!(
427                        hook = %ev.kind.as_str(),
428                        extension = %reg.handler.id(),
429                        len = content.len(),
430                        "Extension injected context"
431                    );
432                    // Accumulate — don't early-return. Multiple extensions can inject.
433                    injections.push(content);
434                }
435                Ok(HookResult::Modify { input }) => {
436                    if !confirms.is_empty() {
437                        // The user is asked about the input as it stood at
438                        // the first Confirm; a rewrite after that point
439                        // would run something they never saw.
440                        tracing::warn!(
441                            hook = %ev.kind.as_str(),
442                            extension = %reg.handler.id(),
443                            confirming = ?confirms.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
444                            "Extension Modify after a pending Confirm IGNORED: the user confirms the input as it was when the first Confirm was raised"
445                        );
446                        continue;
447                    }
448                    tracing::info!(
449                        hook = %ev.kind.as_str(),
450                        extension = %reg.handler.id(),
451                        "Hook modified tool input by extension; later handlers vet the modified input"
452                    );
453                    let mut next = ev.clone();
454                    next.tool_input = Some(input);
455                    modified_event = Some(next);
456                    let id = reg.handler.id().to_string();
457                    if !modified_by.contains(&id) {
458                        modified_by.push(id);
459                    }
460                }
461                Ok(HookResult::Replace { output }) => {
462                    // Two-key gate: subscribing to after_tool_call needs
463                    // `tools.intercept` (observe); rewriting the output
464                    // additionally needs `tools.transform_output`. Without it,
465                    // ignore this handler's Replace (fail-safe: original output
466                    // preserved) and continue the chain.
467                    if !reg.permissions.has(Permission::ToolsTransformOutput) {
468                        tracing::warn!(
469                            hook = %ev.kind.as_str(),
470                            extension = %reg.handler.id(),
471                            "Extension returned Replace without tools.transform_output permission — ignoring transform"
472                        );
473                        continue;
474                    }
475                    let observed_len = ev.tool_output.as_ref().map_or(0, String::len);
476                    tracing::info!(
477                        hook = %ev.kind.as_str(),
478                        extension = %reg.handler.id(),
479                        observed_len,
480                        replacement_len = output.len(),
481                        "Hook replaced tool output by extension"
482                    );
483                    // Persistent audit trail — who rewrote which tool's output
484                    // and when, plus sizes (never content). Best-effort: a
485                    // failed audit write must not break the tool flow.
486                    let _ = crate::extensions::audit::record_tool_output_replace(
487                        reg.handler.id(),
488                        ev.kind.as_str(),
489                        ev.tool_name.clone(),
490                        observed_len,
491                        output.len(),
492                    );
493                    // First transform wins (mirrors Modify). Chaining is a future enhancement.
494                    return HookResult::Replace { output };
495                }
496                Ok(HookResult::Confirm { message, .. }) => {
497                    tracing::info!(
498                        hook = %ev.kind.as_str(),
499                        extension = %reg.handler.id(),
500                        "Hook requested confirmation by extension"
501                    );
502                    // Host-stamped attribution: the "Allow all" latch and
503                    // the dialog key on the extension that actually asked.
504                    // Keep going: a later Block must still win.
505                    confirms.push((reg.handler.id().to_string(), message));
506                    let id = reg.handler.id();
507                    if !source_keys.iter().any(|(k, _)| k == id) {
508                        source_keys.push((id.to_string(), self.extension_generation(id)));
509                    }
510                }
511                Ok(HookResult::ContextPhase { phase }) => {
512                    // Advisory only — never stops the chain. Two-key gated;
513                    // an ignored report is a plain continue.
514                    if let Some(phase) = accept_context_phase(reg, ev.kind, phase) {
515                        if context_phase.is_none() {
516                            context_phase = Some(phase);
517                        } else {
518                            tracing::debug!(
519                                hook = %ev.kind.as_str(),
520                                extension = %reg.handler.id(),
521                                "Extension context_phase superseded by an earlier report — ignoring"
522                            );
523                        }
524                    }
525                }
526                Err(_timeout) => {
527                    tracing::warn!(
528                        hook = %ev.kind.as_str(),
529                        extension = %reg.handler.id(),
530                        timeout_secs = HANDLER_TIMEOUT.as_secs(),
531                        "Hook handler timed out — skipping"
532                    );
533                    // Fail-open: timeout = continue
534                }
535            }
536        }
537
538        // No Block anywhere in the chain: resolve the Confirm(s) last.
539        if !confirms.is_empty() {
540            let tool = event
541                .tool_runtime_name
542                .clone()
543                .or_else(|| event.tool_name.clone());
544            let message = if confirms.len() == 1 {
545                confirms[0].1.clone()
546            } else {
547                confirms
548                    .iter()
549                    .map(|(id, m)| format!("[{id}] {m}"))
550                    .collect::<Vec<_>>()
551                    .join("\n\n")
552            };
553            let sources = source_keys.iter().map(|(id, _)| id.clone()).collect();
554            let generations = source_keys.iter().map(|(_, g)| *g).collect();
555            return HookResult::Confirm {
556                message,
557                sources,
558                generations,
559                tool,
560                modified_input: modified_event.and_then(|e| e.tool_input),
561                modified_by,
562            };
563        }
564        if let Some(input) = modified_event.and_then(|e| e.tool_input) {
565            return HookResult::Modify { input };
566        }
567
568        // Merge accumulated injections from all handlers. An advisory phase
569        // report is returned only when nothing else would be (Block/Inject
570        // take precedence; on_message_complete permits neither).
571        if !injections.is_empty() {
572            HookResult::Inject {
573                content: injections.join("\n\n"),
574            }
575        } else if let Some(phase) = context_phase {
576            HookResult::ContextPhase { phase }
577        } else {
578            HookResult::Continue
579        }
580    }
581
582    /// Emit a hook event to all registered handlers **concurrently**.
583    ///
584    /// All handlers race under a single shared timeout (`per_handler_timeout`).
585    /// Results are collected and the first `Block` wins; injections are merged.
586    ///
587    /// **When to use this over `emit()`:**
588    ///
589    /// Only safe for hook kinds whose handlers are order-independent — i.e.
590    /// where no handler's result depends on another's execution.  Currently
591    /// that applies to:
592    ///   - `on_session_end`: only `Continue` is a valid result; handlers are
593    ///     fire-and-forget notification calls (deck, d20, jawz-widget,
594    ///     synaps-tasks all write to their own stores independently).
595    ///
596    /// **Do NOT use for** `before_tool_call` / `after_tool_call` / `before_message`
597    /// hooks where `Block` / `Modify` / `Replace` / `Inject` semantics require a
598    /// defined winner when two handlers disagree — `join_all` completion order is
599    /// nondeterministic, so the "winner" would be unstable. Those hooks must use
600    /// the sequential [`emit`](Self::emit), which enforces first-wins ordering.
601    ///
602    /// With N extensions and a 5 s per-handler timeout, serial emit takes up
603    /// to N×5 s; concurrent emit collapses that to a single 5 s window
604    /// regardless of N — critical for teardown budgets.
605    pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
606        // H4: real assert, not debug_assert — this invariant protects against
607        // nondeterministic Block/Modify/Replace results in release builds.
608        assert!(
609            !matches!(
610                event.kind,
611                HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
612            ),
613            "emit_concurrent must not be used for transform-capable hooks \
614             ({:?}); their Block/Modify/Replace/Inject results need the \
615             deterministic first-wins ordering of the sequential emit()",
616            event.kind,
617        );
618        // Snapshot handler list (same as emit()).
619        let registrations = {
620            let handlers = self.handlers.read().await;
621            match handlers.get(&event.kind) {
622                Some(regs) if !regs.is_empty() => regs.clone(),
623                _ => return HookResult::Continue, // fast path: no handlers
624            }
625        };
626
627        // Dispatch all handlers simultaneously.
628        let futures: Vec<_> =
629            registrations
630                .iter()
631                .filter(|reg| {
632                    // Apply tool filter before spawning.
633                    reg.tool_filter
634                        .as_deref()
635                        .is_none_or(|filter| tool_filter_matches(filter, event))
636                })
637                .filter(|reg| reg.matcher.as_ref().is_none_or(|m| m.matches(event)))
638                .map(|reg| {
639                    let handler = reg.handler.clone();
640                    let event_clone = event.clone();
641                    async move {
642                        (
643                            reg,
644                            tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone))
645                                .await,
646                        )
647                    }
648                })
649                .collect();
650
651        let results = join_all(futures).await;
652
653        let mut injections: Vec<String> = Vec::new();
654        // First accepted report in completion order (registration order for
655        // the collected results); advisory only, never stops the chain.
656        let mut context_phase: Option<String> = None;
657        for (reg, result) in results {
658            match result {
659                Ok(HookResult::Continue) => {}
660                Ok(HookResult::Block { reason }) => {
661                    return HookResult::Block { reason };
662                }
663                Ok(HookResult::Inject { content }) => {
664                    injections.push(content);
665                }
666                Ok(HookResult::Modify { input }) => {
667                    return HookResult::Modify { input };
668                }
669                Ok(HookResult::Replace { output }) => {
670                    return HookResult::Replace { output };
671                }
672                Ok(HookResult::Confirm { message, .. }) => {
673                    return HookResult::Confirm {
674                        message,
675                        sources: vec![reg.handler.id().to_string()],
676                        generations: vec![self.extension_generation(reg.handler.id())],
677                        tool: event.tool_runtime_name.clone().or_else(|| event.tool_name.clone()),
678                        modified_input: None,
679                        modified_by: vec![],
680                    };
681                }
682                Ok(result @ HookResult::ContextPhase { .. }) => {
683                    if !event.kind.allows_result(&result) {
684                        tracing::warn!(
685                            hook = %event.kind.as_str(),
686                            extension = %reg.handler.id(),
687                            action = hook_result_action(&result),
688                            "Extension returned action not allowed for hook — ignoring"
689                        );
690                        continue;
691                    }
692                    if let HookResult::ContextPhase { phase } = result {
693                        if let Some(phase) = accept_context_phase(reg, event.kind, phase) {
694                            context_phase.get_or_insert(phase);
695                        }
696                    }
697                }
698                Err(_timeout) => {
699                    tracing::warn!(
700                        hook = %event.kind.as_str(),
701                        extension = %reg.handler.id(),
702                        timeout_secs = HANDLER_TIMEOUT.as_secs(),
703                        "Hook handler timed out in concurrent emit — skipping"
704                    );
705                }
706            }
707        }
708
709        if !injections.is_empty() {
710            HookResult::Inject {
711                content: injections.join("\n\n"),
712            }
713        } else if let Some(phase) = context_phase {
714            HookResult::ContextPhase { phase }
715        } else {
716            HookResult::Continue
717        }
718    }
719
720    /// Remove all handlers for a given extension ID.
721    pub async fn unsubscribe_all(&self, extension_id: &str) {
722        *self
723            .generations
724            .lock()
725            .unwrap_or_else(|e| e.into_inner())
726            .entry(extension_id.to_string())
727            .or_insert(0) += 1;
728        let mut handlers = self.handlers.write().await;
729        for regs in handlers.values_mut() {
730            regs.retain(|r| r.handler.id() != extension_id);
731        }
732    }
733
734    /// Number of registered handlers across all hooks. On a worker view:
735    /// the host transform handlers it would dispatch.
736    pub async fn handler_count(&self) -> usize {
737        if let Some(host) = &self.transforms_of {
738            return host
739                .worker_transform_registrations(HookKind::AfterToolCall)
740                .await
741                .map_or(0, |regs| regs.len());
742        }
743        let handlers = self.handlers.read().await;
744        handlers.values().map(|v| v.len()).sum()
745    }
746
747    /// Check if any handlers are registered (for fast-path decisions).
748    pub async fn is_empty(&self) -> bool {
749        if self.is_worker_view() {
750            return self.handler_count().await == 0;
751        }
752        let handlers = self.handlers.read().await;
753        handlers.values().all(|v| v.is_empty())
754    }
755
756    /// Return all (kind, tool_filter) pairs subscribed by the given extension id.
757    /// Sorted by kind name, then by tool_filter (None first), for stable output.
758    pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
759        let handlers = self.handlers.read().await;
760        let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
761        for (kind, regs) in handlers.iter() {
762            for reg in regs {
763                if reg.handler.id() == extension_id {
764                    out.push((*kind, reg.tool_filter.clone()));
765                }
766            }
767        }
768        out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
769        out
770    }
771}
772
773impl Default for HookBus {
774    fn default() -> Self {
775        Self::new()
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::extensions::hooks::events::HookEvent;
783    use crate::extensions::permissions::Permission;
784    use async_trait::async_trait;
785    use std::sync::atomic::{AtomicUsize, Ordering};
786
787    /// Test handler that counts calls and returns a configurable result.
788    struct TestHandler {
789        id: String,
790        call_count: AtomicUsize,
791        result: HookResult,
792    }
793
794    impl TestHandler {
795        fn new(id: &str, result: HookResult) -> Arc<Self> {
796            Arc::new(Self {
797                id: id.to_string(),
798                call_count: AtomicUsize::new(0),
799                result,
800            })
801        }
802
803        fn calls(&self) -> usize {
804            self.call_count.load(Ordering::Relaxed)
805        }
806    }
807
808    #[async_trait]
809    impl crate::extensions::runtime::ExtensionHandler for TestHandler {
810        fn id(&self) -> &str {
811            &self.id
812        }
813
814        async fn handle(&self, _event: &HookEvent) -> HookResult {
815            self.call_count.fetch_add(1, Ordering::Relaxed);
816            self.result.clone()
817        }
818
819        async fn shutdown(&self) {}
820    }
821
822    fn perms_with(perms: &[Permission]) -> PermissionSet {
823        let mut set = PermissionSet::new();
824        for p in perms {
825            set.grant(*p);
826        }
827        set
828    }
829
830    /// emit_after_tool_call returns the substituted output when an
831    /// after_tool_call handler returns Replace — this is the seam that lets an
832    /// extension compress/redact tool output before it enters history.
833    #[tokio::test]
834    #[serial_test::serial(synaps_base_dir)]
835    async fn after_tool_call_replace_substitutes_recorded_output() {
836        // #186: Replace writes the intercept audit — isolate the base dir.
837        let base = crate::test_env::BaseDirGuard::new();
838        let bus = std::sync::Arc::new(HookBus::new());
839        let handler = TestHandler::new(
840            "compressor",
841            HookResult::Replace {
842                output: "COMPRESSED".into(),
843            },
844        );
845        bus.subscribe(
846            HookKind::AfterToolCall,
847            handler,
848            None,
849            None,
850            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
851        )
852        .await
853        .unwrap();
854
855        let recorded = crate::runtime::emit_after_tool_call(
856            &bus,
857            "bash",
858            None,
859            serde_json::json!({"command": "cat huge.log"}),
860            "RAW 10k lines".to_string(),
861            30_000,
862            None,
863        )
864        .await;
865
866        assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
867        let audit = std::fs::read_to_string(
868            crate::extensions::audit::intercept_file_path_for(base.path()),
869        )
870        .expect("intercept audit must land in the isolated base dir");
871        assert!(audit.contains("\"plugin_id\":\"compressor\""));
872    }
873
874    /// With no transform handler, the original output is preserved unchanged.
875    #[tokio::test]
876    async fn after_tool_call_without_replace_keeps_original_output() {
877        let bus = std::sync::Arc::new(HookBus::new());
878        let recorded = crate::runtime::emit_after_tool_call(
879            &bus,
880            "bash",
881            None,
882            serde_json::json!({}),
883            "RAW".to_string(),
884            30_000,
885            None,
886        )
887        .await;
888
889        assert_eq!(recorded, "RAW", "no Replace → original output preserved");
890    }
891
892    /// First Replace wins: once an earlier after_tool_call handler returns
893    /// Replace, later handlers are never reached (mirrors block/modify
894    /// chain-stop). Proves the "first transform wins" comment in emit().
895    #[tokio::test]
896    #[serial_test::serial(synaps_base_dir)]
897    async fn replace_stops_chain_for_after_tool_call() {
898        // #186: Replace writes the intercept audit — isolate the base dir.
899        let _base = crate::test_env::BaseDirGuard::new();
900        let bus = HookBus::new();
901        let first = TestHandler::new(
902            "first",
903            HookResult::Replace {
904                output: "FIRST".into(),
905            },
906        );
907        let second = TestHandler::new(
908            "second",
909            HookResult::Replace {
910                output: "SECOND".into(),
911            },
912        );
913        let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
914
915        bus.subscribe(
916            HookKind::AfterToolCall,
917            first.clone(),
918            None,
919            None,
920            perms.clone(),
921        )
922        .await
923        .unwrap();
924        bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
925            .await
926            .unwrap();
927
928        let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
929        let result = bus.emit(&event).await;
930
931        assert_eq!(
932            result,
933            HookResult::Replace {
934                output: "FIRST".into()
935            }
936        );
937        assert_eq!(first.calls(), 1);
938        assert_eq!(second.calls(), 0); // never reached — first transform wins
939    }
940
941    /// Two-key gate: an extension may subscribe to after_tool_call with only
942    /// `tools.intercept` (observe), but its Replace is IGNORED without the
943    /// additional `tools.transform_output` — the original output is preserved.
944    #[tokio::test]
945    async fn replace_without_transform_permission_is_ignored() {
946        let bus = std::sync::Arc::new(HookBus::new());
947        let handler = TestHandler::new(
948            "observer-only",
949            HookResult::Replace {
950                output: "SNEAKY".into(),
951            },
952        );
953        // Only the observe key — NOT tools.transform_output.
954        bus.subscribe(
955            HookKind::AfterToolCall,
956            handler.clone(),
957            None,
958            None,
959            perms_with(&[Permission::ToolsIntercept]),
960        )
961        .await
962        .unwrap();
963
964        let recorded = crate::runtime::emit_after_tool_call(
965            &bus,
966            "bash",
967            None,
968            serde_json::json!({}),
969            "ORIGINAL".to_string(),
970            30_000,
971            None,
972        )
973        .await;
974
975        assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
976        assert_eq!(
977            recorded, "ORIGINAL",
978            "Replace without tools.transform_output must be ignored — original preserved"
979        );
980    }
981
982    // ── context_phase (advisory work-phase report) ───────────────────────────
983
984    fn phase(p: &str) -> HookResult {
985        HookResult::ContextPhase { phase: p.into() }
986    }
987
988    fn message_complete_event() -> HookEvent {
989        HookEvent::on_message_complete(
990            "done",
991            serde_json::json!({"content_block_count": 1, "has_tool_use": false}),
992        )
993    }
994
995    /// Two-key gate, positive side: `privacy.llm_content` (subscribe) plus
996    /// `session.lifecycle` (report) => the advisory report is returned.
997    #[tokio::test]
998    async fn context_phase_accepted_with_session_lifecycle() {
999        let bus = HookBus::new();
1000        let reporter = TestHandler::new("reporter", phase("new_task"));
1001        bus.subscribe(
1002            HookKind::OnMessageComplete,
1003            reporter.clone(),
1004            None,
1005            None,
1006            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1007        )
1008        .await
1009        .unwrap();
1010
1011        let result = bus.emit(&message_complete_event()).await;
1012        assert_eq!(result, phase("new_task"));
1013        assert_eq!(reporter.calls(), 1);
1014    }
1015
1016    /// Two-key gate, negative side: the observe key alone lets the handler
1017    /// run, but its report is ignored (treated as continue).
1018    #[tokio::test]
1019    async fn context_phase_ignored_without_session_lifecycle() {
1020        let bus = HookBus::new();
1021        let reporter = TestHandler::new("observer-only", phase("new_task"));
1022        bus.subscribe(
1023            HookKind::OnMessageComplete,
1024            reporter.clone(),
1025            None,
1026            None,
1027            perms_with(&[Permission::LlmContent]),
1028        )
1029        .await
1030        .unwrap();
1031
1032        let result = bus.emit(&message_complete_event()).await;
1033        assert_eq!(result, HookResult::Continue);
1034        assert_eq!(reporter.calls(), 1, "handler still runs (it's subscribed)");
1035    }
1036
1037    /// A report does not stop the chain: later observers still run, and the
1038    /// first VALID report wins over later ones (an unprivileged earlier
1039    /// report does not claim the slot).
1040    #[tokio::test]
1041    async fn context_phase_does_not_stop_chain_and_first_valid_wins() {
1042        let bus = HookBus::new();
1043        let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1044        let first = TestHandler::new("first", phase("new_task"));
1045        let second = TestHandler::new("second", phase("wrap_up"));
1046        let observer = TestHandler::new("observer", HookResult::Continue);
1047        let both = || perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]);
1048
1049        bus.subscribe(
1050            HookKind::OnMessageComplete,
1051            unprivileged.clone(),
1052            None,
1053            None,
1054            perms_with(&[Permission::LlmContent]),
1055        )
1056        .await
1057        .unwrap();
1058        for handler in [first.clone(), second.clone()] {
1059            bus.subscribe(HookKind::OnMessageComplete, handler, None, None, both())
1060                .await
1061                .unwrap();
1062        }
1063        bus.subscribe(
1064            HookKind::OnMessageComplete,
1065            observer.clone(),
1066            None,
1067            None,
1068            perms_with(&[Permission::LlmContent]),
1069        )
1070        .await
1071        .unwrap();
1072
1073        let result = bus.emit(&message_complete_event()).await;
1074        assert_eq!(result, phase("new_task"), "first valid report wins");
1075        assert_eq!(unprivileged.calls(), 1);
1076        assert_eq!(first.calls(), 1);
1077        assert_eq!(second.calls(), 1, "later reporter still runs");
1078        assert_eq!(observer.calls(), 1, "later observer still runs");
1079    }
1080
1081    /// A Block from an on_message_complete handler is still ignored, and a
1082    /// valid phase report from another handler is still returned.
1083    #[tokio::test]
1084    async fn context_phase_survives_ignored_block_on_message_complete() {
1085        let bus = HookBus::new();
1086        let blocker = TestHandler::new(
1087            "blocker",
1088            HookResult::Block {
1089                reason: "nope".into(),
1090            },
1091        );
1092        let reporter = TestHandler::new("reporter", phase("execute"));
1093        bus.subscribe(
1094            HookKind::OnMessageComplete,
1095            blocker.clone(),
1096            None,
1097            None,
1098            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1099        )
1100        .await
1101        .unwrap();
1102        bus.subscribe(
1103            HookKind::OnMessageComplete,
1104            reporter.clone(),
1105            None,
1106            None,
1107            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1108        )
1109        .await
1110        .unwrap();
1111
1112        let result = bus.emit(&message_complete_event()).await;
1113        assert_eq!(result, phase("execute"));
1114        assert_eq!(blocker.calls(), 1);
1115        assert_eq!(reporter.calls(), 1);
1116    }
1117
1118    /// context_phase is not allowed on any other hook kind, even with both
1119    /// permissions — sequential and concurrent paths alike.
1120    #[tokio::test]
1121    async fn context_phase_not_allowed_on_other_hooks() {
1122        let bus = HookBus::new();
1123        let all_perms = perms_with(&[
1124            Permission::ToolsIntercept,
1125            Permission::LlmContent,
1126            Permission::SessionLifecycle,
1127        ]);
1128        let handlers: Vec<(HookKind, HookEvent, Arc<TestHandler>)> = vec![
1129            (
1130                HookKind::BeforeToolCall,
1131                HookEvent::before_tool_call("bash", serde_json::json!({})),
1132                TestHandler::new("btc", phase("new_task")),
1133            ),
1134            (
1135                HookKind::AfterToolCall,
1136                HookEvent::after_tool_call("bash", serde_json::json!({}), "o".into()),
1137                TestHandler::new("atc", phase("new_task")),
1138            ),
1139            (
1140                HookKind::BeforeMessage,
1141                HookEvent::before_message("hi"),
1142                TestHandler::new("bm", phase("new_task")),
1143            ),
1144            (
1145                HookKind::OnCompaction,
1146                HookEvent::on_compaction("a", "b", "s", 1, serde_json::json!({})),
1147                TestHandler::new("oc", phase("new_task")),
1148            ),
1149            (
1150                HookKind::OnSessionStart,
1151                HookEvent::on_session_start("s"),
1152                TestHandler::new("oss", phase("new_task")),
1153            ),
1154            (
1155                HookKind::OnSessionEnd,
1156                HookEvent::on_session_end("s", None),
1157                TestHandler::new("ose", phase("new_task")),
1158            ),
1159        ];
1160        for (kind, _, handler) in &handlers {
1161            bus.subscribe(*kind, handler.clone(), None, None, all_perms.clone())
1162                .await
1163                .unwrap();
1164        }
1165        for (kind, event, handler) in &handlers {
1166            let result = bus.emit(event).await;
1167            assert_eq!(result, HookResult::Continue, "{}", kind.as_str());
1168            assert_eq!(handler.calls(), 1, "{}", kind.as_str());
1169        }
1170        // Concurrent path (only legal for non-transform hooks).
1171        let end = HookEvent::on_session_end("s", None);
1172        assert_eq!(bus.emit_concurrent(&end).await, HookResult::Continue);
1173    }
1174
1175    /// The concurrent emit path applies the same two-key gate and first-wins
1176    /// aggregation for on_message_complete.
1177    #[tokio::test]
1178    async fn context_phase_concurrent_emit_two_key_gate() {
1179        let bus = HookBus::new();
1180        let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1181        let reporter = TestHandler::new("reporter", phase("wrap_up"));
1182        bus.subscribe(
1183            HookKind::OnMessageComplete,
1184            unprivileged.clone(),
1185            None,
1186            None,
1187            perms_with(&[Permission::LlmContent]),
1188        )
1189        .await
1190        .unwrap();
1191        bus.subscribe(
1192            HookKind::OnMessageComplete,
1193            reporter.clone(),
1194            None,
1195            None,
1196            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1197        )
1198        .await
1199        .unwrap();
1200
1201        let result = bus.emit_concurrent(&message_complete_event()).await;
1202        assert_eq!(result, phase("wrap_up"));
1203        assert_eq!(unprivileged.calls(), 1);
1204        assert_eq!(reporter.calls(), 1);
1205    }
1206
1207    #[test]
1208    fn trace_env_value_parser_accepts_common_truthy_values() {
1209        for value in ["1", "true", "TRUE", "yes", "on"] {
1210            std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1211            assert!(
1212                extensions_trace_enabled(),
1213                "{value} should enable trace mode"
1214            );
1215        }
1216
1217        for value in ["", "0", "false", "off", "no"] {
1218            std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1219            assert!(
1220                !extensions_trace_enabled(),
1221                "{value:?} should not enable trace mode"
1222            );
1223        }
1224        std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
1225    }
1226
1227    #[tokio::test]
1228    async fn matcher_skips_handler_when_input_does_not_contain_value() {
1229        let bus = HookBus::new();
1230        let handler = TestHandler::new(
1231            "matcher",
1232            HookResult::Block {
1233                reason: "matched".into(),
1234            },
1235        );
1236        let mut perms = PermissionSet::new();
1237        perms.grant(Permission::ToolsIntercept);
1238        bus.subscribe(
1239            HookKind::BeforeToolCall,
1240            handler.clone(),
1241            None,
1242            Some(HookMatcher {
1243                input_contains: Some("danger".to_string()),
1244                input_equals: None,
1245            }),
1246            perms,
1247        )
1248        .await
1249        .unwrap();
1250
1251        let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
1252        assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
1253
1254        let danger =
1255            HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
1256        assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
1257    }
1258
1259    #[test]
1260    fn hook_result_action_names_are_stable_for_trace_logs() {
1261        assert_eq!(hook_result_action(&HookResult::Continue), "continue");
1262        assert_eq!(
1263            hook_result_action(&HookResult::Block {
1264                reason: "stop".into(),
1265            }),
1266            "block"
1267        );
1268        assert_eq!(
1269            hook_result_action(&HookResult::Inject {
1270                content: "context".into(),
1271            }),
1272            "inject"
1273        );
1274        assert_eq!(
1275            hook_result_action(&HookResult::confirm("Proceed?")),
1276            "confirm"
1277        );
1278        assert_eq!(
1279            hook_result_action(&HookResult::Modify {
1280                input: serde_json::json!({"command": "echo safe"}),
1281            }),
1282            "modify"
1283        );
1284    }
1285
1286    #[tokio::test]
1287    async fn empty_bus_returns_continue() {
1288        let bus = HookBus::new();
1289        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1290        let result = bus.emit(&event).await;
1291        assert!(matches!(result, HookResult::Continue));
1292    }
1293
1294    #[tokio::test]
1295    async fn handler_receives_events() {
1296        let bus = HookBus::new();
1297        let handler = TestHandler::new("test-ext", HookResult::Continue);
1298        let perms = perms_with(&[Permission::ToolsIntercept]);
1299
1300        bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1301            .await
1302            .unwrap();
1303
1304        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1305        bus.emit(&event).await;
1306
1307        assert_eq!(handler.calls(), 1);
1308    }
1309
1310    async fn bus_with(handlers: Vec<Arc<TestHandler>>) -> HookBus {
1311        let bus = HookBus::new();
1312        let perms = perms_with(&[Permission::ToolsIntercept]);
1313        for h in handlers {
1314            bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1315                .await
1316                .unwrap();
1317        }
1318        bus
1319    }
1320
1321    /// #148 I1: a Confirm no longer stops the chain. Later handlers run, and
1322    /// with nothing else to say the Confirm is returned (resolved last).
1323    #[tokio::test]
1324    async fn confirm_does_not_stop_chain_for_before_tool_call() {
1325        let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run this command?"));
1326        let after = TestHandler::new("after", HookResult::Continue);
1327        let bus = bus_with(vec![confirmer.clone(), after.clone()]).await;
1328
1329        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1330        let result = bus.emit(&event).await;
1331
1332        assert!(matches!(result, HookResult::Confirm { .. }));
1333        assert_eq!(confirmer.calls(), 1);
1334        assert_eq!(after.calls(), 1, "later handlers are still evaluated");
1335    }
1336
1337    /// A later extension's Block wins over an earlier Confirm.
1338    #[tokio::test]
1339    async fn later_block_wins_over_earlier_confirm() {
1340        let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1341        let blocker = TestHandler::new(
1342            "policy",
1343            HookResult::Block {
1344                reason: "policy veto".into(),
1345            },
1346        );
1347        let bus = bus_with(vec![confirmer, blocker]).await;
1348        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1349        assert!(matches!(
1350            bus.emit(&event).await,
1351            HookResult::Block { reason } if reason == "policy veto"
1352        ));
1353    }
1354
1355    /// Two extensions confirming the same call: one prompt carrying both
1356    /// sources (so "Allow all" from one cannot silently skip the other).
1357    #[tokio::test]
1358    async fn confirms_from_several_extensions_merge_sources() {
1359        let a = TestHandler::new("ext-a", HookResult::confirm("A asks"));
1360        let b = TestHandler::new("ext-b", HookResult::confirm("B asks"));
1361        let bus = bus_with(vec![a, b]).await;
1362        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1363        match bus.emit(&event).await {
1364            HookResult::Confirm {
1365                message, sources, ..
1366            } => {
1367                assert_eq!(sources, vec!["ext-a".to_string(), "ext-b".to_string()]);
1368                assert!(message.contains("A asks") && message.contains("B asks"), "{message}");
1369                assert!(message.contains("[ext-a]") && message.contains("[ext-b]"), "{message}");
1370            }
1371            other => panic!("expected merged Confirm, got {other:?}"),
1372        }
1373    }
1374
1375    /// With the confirming extension latched ("Allow all"), a later
1376    /// extension's Block is still evaluated and still blocks.
1377    #[tokio::test]
1378    async fn armed_latch_never_skips_a_later_block() {
1379        let confirmer = TestHandler::new("jev", HookResult::confirm("Run?"));
1380        let blocker = TestHandler::new(
1381            "policy",
1382            HookResult::Block {
1383                reason: "policy veto".into(),
1384            },
1385        );
1386        let bus = bus_with(vec![confirmer, blocker.clone()]).await;
1387        let latch = crate::tools::SessionConfirmLatch::new();
1388        latch.allow(&[("jev".to_string(), 0)]);
1389        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1390        let decision = crate::runtime::resolve_before_tool_call_decision(
1391            serde_json::json!({}),
1392            bus.emit(&event).await,
1393            None,
1394            false,
1395            Some(&latch),
1396        )
1397        .await;
1398        assert_eq!(blocker.calls(), 1);
1399        assert!(matches!(
1400            decision,
1401            crate::runtime::BeforeToolCallDecision::Block { reason } if reason == "policy veto"
1402        ));
1403    }
1404
1405    /// Records the `tool_input` it was shown, then returns `result`.
1406    struct InputSeen {
1407        id: String,
1408        seen: std::sync::Mutex<Option<serde_json::Value>>,
1409        result: HookResult,
1410    }
1411    #[async_trait]
1412    impl crate::extensions::runtime::ExtensionHandler for InputSeen {
1413        fn id(&self) -> &str {
1414            &self.id
1415        }
1416        async fn handle(&self, event: &HookEvent) -> HookResult {
1417            *self.seen.lock().unwrap() = event.tool_input.clone();
1418            self.result.clone()
1419        }
1420        async fn shutdown(&self) {}
1421    }
1422    fn input_seen(id: &str, result: HookResult) -> Arc<InputSeen> {
1423        Arc::new(InputSeen {
1424            id: id.into(),
1425            seen: std::sync::Mutex::new(None),
1426            result,
1427        })
1428    }
1429    fn modify(cmd: &str) -> HookResult {
1430        HookResult::Modify {
1431            input: serde_json::json!({ "command": cmd }),
1432        }
1433    }
1434    async fn bus_of(handlers: Vec<Arc<dyn crate::extensions::runtime::ExtensionHandler>>) -> HookBus {
1435        let bus = HookBus::new();
1436        let perms = perms_with(&[Permission::ToolsIntercept]);
1437        for h in handlers {
1438            bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1439                .await
1440                .unwrap();
1441        }
1442        bus
1443    }
1444
1445    /// prA I1: a Modify no longer ends the chain. A later Block sees the
1446    /// MODIFIED input and still wins.
1447    #[tokio::test]
1448    async fn modify_then_block_blocks_and_blocker_vets_modified_input() {
1449        let blocker = input_seen(
1450            "policy",
1451            HookResult::Block {
1452                reason: "veto".into(),
1453            },
1454        );
1455        let bus = bus_of(vec![input_seen("rewriter", modify("rm -rf /")), blocker.clone()]).await;
1456        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1457        assert!(matches!(bus.emit(&event).await, HookResult::Block { reason } if reason == "veto"));
1458        assert_eq!(
1459            blocker.seen.lock().unwrap().clone(),
1460            Some(serde_json::json!({"command": "rm -rf /"})),
1461            "the blocker must vet the rewritten input, not the original"
1462        );
1463    }
1464
1465    /// Modify then Confirm: the user is still asked, and approval runs the
1466    /// modified input.
1467    #[tokio::test]
1468    async fn modify_then_confirm_prompts_with_modified_input() {
1469        let confirmer = input_seen("jev", HookResult::confirm("ok?"));
1470        let bus = bus_of(vec![input_seen("rewriter", modify("echo safe")), confirmer.clone()]).await;
1471        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1472        let result = bus.emit(&event).await;
1473        assert_eq!(
1474            confirmer.seen.lock().unwrap().clone(),
1475            Some(serde_json::json!({"command": "echo safe"}))
1476        );
1477        match &result {
1478            HookResult::Confirm {
1479                sources,
1480                modified_input,
1481                ..
1482            } => {
1483                assert_eq!(sources, &vec!["jev".to_string()]);
1484                assert_eq!(
1485                    modified_input,
1486                    &Some(serde_json::json!({"command": "echo safe"}))
1487                );
1488            }
1489            other => panic!("expected Confirm, got {other:?}"),
1490        }
1491        // Approved (here: auto-approve) → the MODIFIED input runs.
1492        let decision = crate::runtime::resolve_before_tool_call_decision(
1493            serde_json::json!({"command": "ls"}),
1494            result,
1495            None,
1496            true,
1497            None,
1498        )
1499        .await;
1500        assert!(matches!(
1501            decision,
1502            crate::runtime::BeforeToolCallDecision::Continue { input }
1503                if input == serde_json::json!({"command": "echo safe"})
1504        ));
1505    }
1506
1507    /// Two modifiers compose in order; with nothing else, the final input is
1508    /// returned as a Modify.
1509    #[tokio::test]
1510    async fn modifiers_compose_and_final_input_is_returned() {
1511        let second = input_seen("second", modify("two"));
1512        let bus = bus_of(vec![input_seen("first", modify("one")), second.clone()]).await;
1513        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "zero"}));
1514        assert!(matches!(
1515            bus.emit(&event).await,
1516            HookResult::Modify { input } if input == serde_json::json!({"command": "two"})
1517        ));
1518        assert_eq!(
1519            second.seen.lock().unwrap().clone(),
1520            Some(serde_json::json!({"command": "one"}))
1521        );
1522    }
1523
1524    /// Unloading / reinstalling an extension under the same id bumps its
1525    /// load generation, so a latch armed for the old instance doesn't match.
1526    #[tokio::test]
1527    async fn reinstall_under_same_id_gets_a_new_confirm_generation() {
1528        let bus = bus_with(vec![TestHandler::new("jev", HookResult::confirm("ok?"))]).await;
1529        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1530        let gens = |r: HookResult| match r {
1531            HookResult::Confirm { generations, .. } => generations,
1532            other => panic!("{other:?}"),
1533        };
1534        assert_eq!(gens(bus.emit(&event).await), vec![0]);
1535        bus.unsubscribe_all("jev").await;
1536        bus.subscribe(
1537            HookKind::BeforeToolCall,
1538            TestHandler::new("jev", HookResult::confirm("ok?")),
1539            None,
1540            None,
1541            perms_with(&[Permission::ToolsIntercept]),
1542        )
1543        .await
1544        .unwrap();
1545        let latch = crate::tools::SessionConfirmLatch::new();
1546        latch.allow(&[("jev".to_string(), 0)]);
1547        let result = bus.emit(&event).await;
1548        let decision = crate::runtime::resolve_before_tool_call_decision(
1549            serde_json::json!({}),
1550            result.clone(),
1551            None,
1552            false,
1553            Some(&latch),
1554        )
1555        .await;
1556        assert_eq!(gens(result), vec![1]);
1557        assert!(
1558            matches!(decision, crate::runtime::BeforeToolCallDecision::Block { .. }),
1559            "old consent must not carry over to the reinstalled extension"
1560        );
1561    }
1562
1563    /// A Modify after a pending Confirm is not applied (the user is asked to
1564    /// confirm the original call) — same outcome as before, when the chain
1565    /// stopped at the Confirm.
1566    #[tokio::test]
1567    async fn modify_after_confirm_is_ignored() {
1568        let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1569        let modifier = TestHandler::new(
1570            "modifier",
1571            HookResult::Modify {
1572                input: serde_json::json!({"command": "echo other"}),
1573            },
1574        );
1575        let bus = bus_with(vec![confirmer, modifier]).await;
1576        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1577        assert!(matches!(
1578            bus.emit(&event).await,
1579            HookResult::Confirm { modified_input: None, .. }
1580        ), "Confirm→Modify: the modify is ignored (logged), the original input is confirmed");
1581    }
1582
1583    #[tokio::test]
1584    async fn confirm_is_stamped_with_source_extension_and_tool() {
1585        let bus = HookBus::new();
1586        let confirmer = TestHandler::new("jev", HookResult::confirm("Run ls?"));
1587        let perms = perms_with(&[Permission::ToolsIntercept]);
1588        bus.subscribe(HookKind::BeforeToolCall, confirmer, None, None, perms)
1589            .await
1590            .unwrap();
1591        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1592        match bus.emit(&event).await {
1593            HookResult::Confirm {
1594                message,
1595                sources,
1596                tool,
1597                ..
1598            } => {
1599                assert_eq!(message, "Run ls?");
1600                assert_eq!(sources, vec!["jev".to_string()]);
1601                assert_eq!(tool.as_deref(), Some("bash"));
1602            }
1603            other => panic!("expected Confirm, got {other:?}"),
1604        }
1605    }
1606
1607    #[tokio::test]
1608    async fn confirm_is_ignored_for_non_tool_hooks() {
1609        let bus = HookBus::new();
1610        let confirmer = TestHandler::new(
1611            "confirmer",
1612            HookResult::confirm("Not allowed here"),
1613        );
1614        let perms = perms_with(&[Permission::LlmContent]);
1615
1616        bus.subscribe(
1617            HookKind::BeforeMessage,
1618            confirmer.clone(),
1619            None,
1620            None,
1621            perms,
1622        )
1623        .await
1624        .unwrap();
1625
1626        let event = HookEvent::before_message("hello");
1627        let result = bus.emit(&event).await;
1628
1629        assert!(matches!(result, HookResult::Continue));
1630        assert_eq!(confirmer.calls(), 1);
1631    }
1632
1633    #[tokio::test]
1634    async fn block_stops_chain() {
1635        let bus = HookBus::new();
1636        let blocker = TestHandler::new(
1637            "blocker",
1638            HookResult::Block {
1639                reason: "dangerous".into(),
1640            },
1641        );
1642        let after = TestHandler::new("after", HookResult::Continue);
1643        let perms = perms_with(&[Permission::ToolsIntercept]);
1644
1645        bus.subscribe(
1646            HookKind::BeforeToolCall,
1647            blocker.clone(),
1648            None,
1649            None,
1650            perms.clone(),
1651        )
1652        .await
1653        .unwrap();
1654        bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
1655            .await
1656            .unwrap();
1657
1658        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1659        let result = bus.emit(&event).await;
1660
1661        assert!(matches!(result, HookResult::Block { .. }));
1662        assert_eq!(blocker.calls(), 1);
1663        assert_eq!(after.calls(), 0); // never reached
1664    }
1665
1666    /// prA I1 (was `modify_stops_chain`, which pinned the bypass): a later
1667    /// Block still runs after a Modify, and wins.
1668    #[tokio::test]
1669    async fn modify_does_not_stop_chain_later_block_wins() {
1670        let modifier = TestHandler::new(
1671            "modifier",
1672            HookResult::Modify {
1673                input: serde_json::json!({"command": "echo safe"}),
1674            },
1675        );
1676        let after = TestHandler::new(
1677            "after",
1678            HookResult::Block {
1679                reason: "vetoed".into(),
1680            },
1681        );
1682        let bus = bus_with(vec![modifier.clone(), after.clone()]).await;
1683        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
1684        let result = bus.emit(&event).await;
1685        assert!(matches!(result, HookResult::Block { reason } if reason == "vetoed"));
1686        assert_eq!(modifier.calls(), 1);
1687        assert_eq!(after.calls(), 1);
1688    }
1689
1690    #[tokio::test]
1691    async fn tool_filter_only_matches_specified_tool() {
1692        let bus = HookBus::new();
1693        let handler = TestHandler::new("bash-only", HookResult::Continue);
1694        let perms = perms_with(&[Permission::ToolsIntercept]);
1695
1696        bus.subscribe(
1697            HookKind::AfterToolCall,
1698            handler.clone(),
1699            Some("bash".into()),
1700            None,
1701            perms,
1702        )
1703        .await
1704        .unwrap();
1705
1706        // Should NOT fire for 'read' tool
1707        let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
1708        bus.emit(&event).await;
1709        assert_eq!(handler.calls(), 0);
1710
1711        // SHOULD fire for 'bash' tool
1712        let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
1713        bus.emit(&event).await;
1714        assert_eq!(handler.calls(), 1);
1715    }
1716
1717    /// A guard written for `edit`/`write` (from before `multi_edit`
1718    /// existed) still sees `multi_edit` before the call, so it can block or
1719    /// confirm it. Output transforms stay exact.
1720    #[tokio::test]
1721    async fn edit_and_write_filters_also_guard_multi_edit_before_the_call() {
1722        for filter in ["edit", "write"] {
1723            let bus = HookBus::new();
1724            let guard = TestHandler::new(
1725                "guard",
1726                HookResult::Block {
1727                    reason: "guarded".into(),
1728                },
1729            );
1730            let after = TestHandler::new("after", HookResult::Continue);
1731            let perms = perms_with(&[Permission::ToolsIntercept]);
1732            bus.subscribe(HookKind::BeforeToolCall, guard.clone(), Some(filter.into()), None, perms.clone())
1733                .await
1734                .unwrap();
1735            bus.subscribe(HookKind::AfterToolCall, after.clone(), Some(filter.into()), None, perms)
1736                .await
1737                .unwrap();
1738            let input = serde_json::json!({"edits": [{"path": "/etc/hosts", "op": "append", "text": "x"}]});
1739            let blocked = bus.emit(&HookEvent::before_tool_call("multi_edit", input.clone())).await;
1740            assert!(matches!(blocked, HookResult::Block { reason } if reason == "guarded"), "{filter}");
1741            assert_eq!(guard.calls(), 1, "{filter}");
1742            let other = bus.emit(&HookEvent::before_tool_call("read", serde_json::json!({}))).await;
1743            assert!(matches!(other, HookResult::Continue), "{filter}: only the file-changing tool");
1744            bus.emit(&HookEvent::after_tool_call("multi_edit", input, "ok".into())).await;
1745            assert_eq!(after.calls(), 0, "{filter}: after_tool_call filters stay exact");
1746        }
1747    }
1748
1749    #[tokio::test]
1750    async fn permission_denied_rejects_subscribe() {
1751        let bus = HookBus::new();
1752        let handler = TestHandler::new("no-perms", HookResult::Continue);
1753        let perms = PermissionSet::new(); // empty — no permissions
1754
1755        let result = bus
1756            .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1757            .await;
1758
1759        assert!(result.is_err());
1760        assert!(result.unwrap_err().contains("lacks permission"));
1761    }
1762
1763    #[tokio::test]
1764    async fn unsubscribe_removes_handlers() {
1765        let bus = HookBus::new();
1766        let handler = TestHandler::new("removable", HookResult::Continue);
1767        let perms = perms_with(&[Permission::ToolsIntercept]);
1768
1769        bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1770            .await
1771            .unwrap();
1772        assert_eq!(bus.handler_count().await, 1);
1773
1774        bus.unsubscribe_all("removable").await;
1775        assert_eq!(bus.handler_count().await, 0);
1776    }
1777
1778    #[tokio::test]
1779    async fn subscriptions_for_lists_only_matching_extension() {
1780        let bus = HookBus::new();
1781        let alpha = TestHandler::new("alpha", HookResult::Continue);
1782        let beta = TestHandler::new("beta", HookResult::Continue);
1783        let perms = perms_with(&[Permission::ToolsIntercept]);
1784
1785        bus.subscribe(
1786            HookKind::BeforeToolCall,
1787            alpha.clone(),
1788            Some("bash".into()),
1789            None,
1790            perms.clone(),
1791        )
1792        .await
1793        .unwrap();
1794        bus.subscribe(
1795            HookKind::AfterToolCall,
1796            alpha.clone(),
1797            None,
1798            None,
1799            perms.clone(),
1800        )
1801        .await
1802        .unwrap();
1803        bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1804            .await
1805            .unwrap();
1806
1807        let alpha_subs = bus.subscriptions_for("alpha").await;
1808        assert_eq!(alpha_subs.len(), 2);
1809        // sorted by kind name then by tool_filter (None first)
1810        assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1811        assert_eq!(alpha_subs[0].1, None);
1812        assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1813        assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1814
1815        let beta_subs = bus.subscriptions_for("beta").await;
1816        assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1817
1818        let none_subs = bus.subscriptions_for("ghost").await;
1819        assert!(none_subs.is_empty());
1820    }
1821
1822    #[tokio::test]
1823    async fn is_empty_reflects_state() {
1824        let bus = HookBus::new();
1825        assert!(bus.is_empty().await);
1826
1827        let handler = TestHandler::new("ext", HookResult::Continue);
1828        let perms = perms_with(&[Permission::ToolsIntercept]);
1829        bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1830            .await
1831            .unwrap();
1832        assert!(!bus.is_empty().await);
1833    }
1834
1835    // ────────────────────────────────────────────────────────────────────────
1836    // compress-then-truncate ordering (Synaps Fork 1) — RED stub
1837    // ────────────────────────────────────────────────────────────────────────
1838
1839    #[cfg(feature = "command-exec")]
1840    #[derive(Debug)]
1841    struct RecordingHandler {
1842        id: String,
1843        recorded_len: Arc<AtomicUsize>,
1844        result: HookResult,
1845    }
1846    #[cfg(feature = "command-exec")]
1847    impl RecordingHandler {
1848        fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1849            let recorded_len = Arc::new(AtomicUsize::new(0));
1850            let h = Arc::new(Self {
1851                id: id.to_string(),
1852                recorded_len: recorded_len.clone(),
1853                result,
1854            });
1855            (h, recorded_len)
1856        }
1857    }
1858    #[cfg(feature = "command-exec")]
1859    #[async_trait]
1860    impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1861        fn id(&self) -> &str {
1862            &self.id
1863        }
1864        async fn handle(&self, event: &HookEvent) -> HookResult {
1865            if let Some(out) = event.tool_output.as_ref() {
1866                self.recorded_len.store(out.len(), Ordering::Relaxed);
1867            }
1868            self.result.clone()
1869        }
1870        async fn shutdown(&self) {}
1871    }
1872
1873    /// Test A (RED stub, current signature) — proves the bug.
1874    #[cfg(feature = "command-exec")]
1875    #[tokio::test]
1876    async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1877        use crate::tools::Tool;
1878        let bus = std::sync::Arc::new(HookBus::new());
1879        let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1880        bus.subscribe(
1881            HookKind::AfterToolCall,
1882            handler,
1883            None,
1884            None,
1885            perms_with(&[Permission::ToolsIntercept]),
1886        )
1887        .await
1888        .unwrap();
1889
1890        let ctx = crate::tools::ToolContext {
1891            channels: crate::tools::ToolChannels {
1892                tx_delta: None,
1893                tx_events: None,
1894            },
1895            capabilities: crate::tools::ToolCapabilities {
1896                session_allow_all: None,
1897                launch_cancel: None,
1898                memory_backend: None,
1899                telemetry: None,
1900                watcher_exit_path: None,
1901                tool_register_tx: None,
1902                session_manager: None,
1903                subagent_registry: None,
1904                event_queue: None,
1905                delegation_parent: None,
1906                codex_parent_plan: None,
1907                secret_prompt: None,
1908                orchestration: None,
1909                tool_activation: None,
1910                mcp_leases: None,
1911                extension_leases: None,
1912                memory_context: None,
1913                cwd: None,
1914                env: None,
1915                env_stripped: Vec::new(),
1916                env_warned: Default::default(),
1917                session_id: None,
1918            },
1919            limits: crate::tools::ToolLimits {
1920                max_tool_output: 30_000,
1921                max_tool_buffer: 256 * 1024,
1922                bash_timeout: 30,
1923                bash_max_timeout: 300,
1924                subagent_timeout: 300,
1925            },
1926        };
1927
1928        let bash = crate::tools::BashTool;
1929        let output = bash
1930            .execute(
1931                serde_json::json!({"command": "yes hello | head -c 150000"}),
1932                ctx,
1933            )
1934            .await
1935            .expect("bash should succeed");
1936
1937        let _ = crate::runtime::emit_after_tool_call(
1938            &bus,
1939            "bash",
1940            None,
1941            serde_json::json!({"command": "yes hello | head -c 150000"}),
1942            output,
1943            30_000,
1944            None,
1945        )
1946        .await;
1947
1948        let len = recorded_len.load(Ordering::Relaxed);
1949        assert!(
1950            len > 100_000,
1951            "after_tool_call handler was starved: received {len} bytes — \
1952             expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1953        );
1954    }
1955
1956    /// Test B: when a transform Replaces with a >max_tool_output string, the
1957    /// FINAL emit_after_tool_call return is ≤ max_tool_output (+marker overhead).
1958    /// Proves compress-then-truncate ordering at the new site.
1959    #[tokio::test]
1960    #[serial_test::serial(synaps_base_dir)]
1961    async fn final_output_truncated_to_max_tool_output_after_hook() {
1962        // #186: Replace writes the intercept audit — isolate the base dir.
1963        let _base = crate::test_env::BaseDirGuard::new();
1964        let bus = std::sync::Arc::new(HookBus::new());
1965        let big: String = "x".repeat(100_000);
1966        let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1967        bus.subscribe(
1968            HookKind::AfterToolCall,
1969            handler,
1970            None,
1971            None,
1972            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1973        )
1974        .await
1975        .unwrap();
1976
1977        let result = crate::runtime::emit_after_tool_call(
1978            &bus,
1979            "bash",
1980            None,
1981            serde_json::json!({}),
1982            "tiny original".to_string(),
1983            30_000,
1984            None,
1985        )
1986        .await;
1987
1988        assert!(
1989            result.len() <= 30_000 + 200,
1990            "post-hook output should be capped at max_tool_output (got {} bytes)",
1991            result.len()
1992        );
1993    }
1994
1995    /// Test C: with NO transform, the final output is byte-identical to the
1996    /// legacy truncate_tool_result(...) result — behavior preservation invariant.
1997    #[tokio::test]
1998    async fn no_transform_extension_preserves_legacy_truncation() {
1999        let bus = std::sync::Arc::new(HookBus::new());
2000        let huge: String = "y".repeat(80_000);
2001
2002        let result = crate::runtime::emit_after_tool_call(
2003            &bus,
2004            "bash",
2005            None,
2006            serde_json::json!({}),
2007            huge.clone(),
2008            30_000,
2009            None,
2010        )
2011        .await;
2012
2013        let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
2014        assert_eq!(
2015            result, legacy,
2016            "no-extension path must be byte-identical to legacy truncation"
2017        );
2018    }
2019
2020    /// Test D: emit_after_tool_call unit-level — Continue and Replace branches
2021    /// both truncate to max_tool_output, and truncation is char-boundary safe.
2022    #[tokio::test]
2023    #[serial_test::serial(synaps_base_dir)]
2024    async fn emit_after_tool_call_truncates_char_boundary_safe() {
2025        // #186: Replace writes the intercept audit — isolate the base dir.
2026        let _base = crate::test_env::BaseDirGuard::new();
2027        // Continue path with multibyte content.
2028        let bus = std::sync::Arc::new(HookBus::new());
2029        let multibyte: String = "héllo🌟".repeat(5_000);
2030        assert!(multibyte.len() > 30_000);
2031
2032        let result = crate::runtime::emit_after_tool_call(
2033            &bus,
2034            "bash",
2035            None,
2036            serde_json::json!({}),
2037            multibyte,
2038            30_000,
2039            None,
2040        )
2041        .await;
2042        assert!(result.is_char_boundary(result.len()));
2043
2044        // Replace path with multibyte content.
2045        let bus2 = std::sync::Arc::new(HookBus::new());
2046        let big_multi = "✨".repeat(20_000);
2047        assert!(big_multi.len() > 30_000);
2048        let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
2049        bus2.subscribe(
2050            HookKind::AfterToolCall,
2051            handler,
2052            None,
2053            None,
2054            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
2055        )
2056        .await
2057        .unwrap();
2058
2059        let result2 = crate::runtime::emit_after_tool_call(
2060            &bus2,
2061            "bash",
2062            None,
2063            serde_json::json!({}),
2064            "orig".to_string(),
2065            30_000,
2066            None,
2067        )
2068        .await;
2069        assert!(result2.is_char_boundary(result2.len()));
2070    }
2071
2072    #[tokio::test]
2073    async fn keyed_injection_isolated_per_session() {
2074        let bus = HookBus::new();
2075        bus.set_session_injection_for("s1", "one".into()).await;
2076        bus.set_session_injection_for("s2", "two".into()).await;
2077        assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
2078        assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2079        assert_eq!(bus.session_injection_for("s3").await, None);
2080    }
2081
2082    #[tokio::test]
2083    async fn clear_removes_only_that_session() {
2084        let bus = HookBus::new();
2085        bus.set_session_injection_for("s1", "one".into()).await;
2086        bus.set_session_injection_for("s2", "two".into()).await;
2087        bus.clear_session_injection("s1").await;
2088        assert_eq!(bus.session_injection_for("s1").await, None);
2089        assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2090    }
2091
2092    #[tokio::test]
2093    #[allow(deprecated)]
2094    async fn deprecated_shims_use_empty_key() {
2095        let bus = HookBus::new();
2096        bus.set_session_injection("legacy".into()).await;
2097        assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
2098        assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
2099        assert_eq!(bus.session_injection_for("s1").await, None);
2100    }
2101}
2102
2103/// Subagent workers' hook view ([`HookBus::worker_transforms`]): only the
2104/// host's `after_tool_call` output transforms, live, dispatched without
2105/// ever (re)starting an extension.
2106#[cfg(test)]
2107mod worker_view_tests {
2108    use super::*;
2109    use crate::extensions::permissions::Permission;
2110    use async_trait::async_trait;
2111    use std::sync::atomic::{AtomicUsize, Ordering};
2112
2113    /// Counts `handle` / `handle_no_spawn` calls separately. `result` is
2114    /// what both return; `tag_output` instead replaces with
2115    /// `"<tag>:<tool_output>"` (per-call, so concurrent callers can tell
2116    /// their replacements apart). `delay` sleeps first.
2117    struct Probe {
2118        id: String,
2119        result: HookResult,
2120        tag_output: Option<String>,
2121        delay: Duration,
2122        handled: AtomicUsize,
2123        handled_no_spawn: AtomicUsize,
2124    }
2125
2126    impl Probe {
2127        fn build(id: &str, result: HookResult, tag: Option<&str>, delay: Duration) -> Arc<Self> {
2128            Arc::new(Self {
2129                id: id.into(),
2130                result,
2131                tag_output: tag.map(str::to_string),
2132                delay,
2133                handled: AtomicUsize::new(0),
2134                handled_no_spawn: AtomicUsize::new(0),
2135            })
2136        }
2137
2138        fn new(id: &str, result: HookResult) -> Arc<Self> {
2139            Self::build(id, result, None, Duration::ZERO)
2140        }
2141
2142        fn tagging(id: &str, tag: &str, delay: Duration) -> Arc<Self> {
2143            Self::build(id, HookResult::Continue, Some(tag), delay)
2144        }
2145
2146        fn calls(&self) -> (usize, usize) {
2147            (
2148                self.handled.load(Ordering::SeqCst),
2149                self.handled_no_spawn.load(Ordering::SeqCst),
2150            )
2151        }
2152
2153        async fn answer(&self, event: &HookEvent) -> HookResult {
2154            if !self.delay.is_zero() {
2155                tokio::time::sleep(self.delay).await;
2156            }
2157            match &self.tag_output {
2158                Some(tag) => HookResult::Replace {
2159                    output: format!("{tag}:{}", event.tool_output.as_deref().unwrap_or("")),
2160                },
2161                None => self.result.clone(),
2162            }
2163        }
2164    }
2165
2166    #[async_trait]
2167    impl crate::extensions::runtime::ExtensionHandler for Probe {
2168        fn id(&self) -> &str {
2169            &self.id
2170        }
2171        async fn handle(&self, event: &HookEvent) -> HookResult {
2172            self.handled.fetch_add(1, Ordering::SeqCst);
2173            self.answer(event).await
2174        }
2175        async fn handle_no_spawn(&self, event: &HookEvent) -> HookResult {
2176            self.handled_no_spawn.fetch_add(1, Ordering::SeqCst);
2177            self.answer(event).await
2178        }
2179        async fn shutdown(&self) {}
2180    }
2181
2182    fn perms(perms: &[Permission]) -> PermissionSet {
2183        let mut set = PermissionSet::new();
2184        for p in perms {
2185            set.grant(*p);
2186        }
2187        set
2188    }
2189
2190    fn transform() -> PermissionSet {
2191        perms(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput])
2192    }
2193
2194    async fn after(bus: &Arc<HookBus>, output: &str) -> String {
2195        crate::runtime::emit_after_tool_call(
2196            bus,
2197            "bash",
2198            None,
2199            serde_json::json!({"command": "cat big.log"}),
2200            output.to_string(),
2201            30_000,
2202            None,
2203        )
2204        .await
2205    }
2206
2207    /// The host bus a worker views: a confirm and a block on
2208    /// before_tool_call, an inject on before_message, an observe-only
2209    /// after_tool_call, and one real output transform.
2210    async fn host_with_every_kind() -> (Arc<HookBus>, Vec<Arc<Probe>>, Arc<Probe>) {
2211        let host = Arc::new(HookBus::new());
2212        let confirm = Probe::new(
2213            "confirmer",
2214            HookResult::Confirm {
2215                message: "really?".into(),
2216                sources: vec![],
2217                generations: vec![],
2218                tool: None,
2219                modified_input: None,
2220                modified_by: vec![],
2221            },
2222        );
2223        let block = Probe::new("blocker", HookResult::Block { reason: "no".into() });
2224        let inject = Probe::new("injector", HookResult::Inject { content: "ctx".into() });
2225        let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2226        let crush = Probe::new("crush", HookResult::Replace { output: "CRUSHED".into() });
2227        let intercept = perms(&[Permission::ToolsIntercept]);
2228        host.subscribe(HookKind::BeforeToolCall, confirm.clone(), None, None, intercept.clone())
2229            .await
2230            .unwrap();
2231        host.subscribe(HookKind::BeforeToolCall, block.clone(), None, None, transform())
2232            .await
2233            .unwrap();
2234        host.subscribe(
2235            HookKind::BeforeMessage,
2236            inject.clone(),
2237            None,
2238            None,
2239            perms(&[Permission::LlmContent, Permission::ToolsTransformOutput]),
2240        )
2241        .await
2242        .unwrap();
2243        // Observe-only first, so on the host it is reached before `crush`.
2244        host.subscribe(HookKind::AfterToolCall, observer.clone(), None, None, intercept)
2245            .await
2246            .unwrap();
2247        host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform())
2248            .await
2249            .unwrap();
2250        (host, vec![confirm, block, inject, observer], crush)
2251    }
2252
2253    #[tokio::test]
2254    #[serial_test::serial(synaps_base_dir)]
2255    async fn a_worker_runs_only_the_hosts_after_tool_call_transforms() {
2256        let _base = crate::test_env::BaseDirGuard::new();
2257        let (host, others, crush) = host_with_every_kind().await;
2258        let worker = HookBus::worker_transforms(&host);
2259        assert!(worker.is_worker_view() && !host.is_worker_view());
2260
2261        // The worker's bash result is replaced by the transform...
2262        assert_eq!(after(&worker, "RAW 10k lines").await, "CRUSHED");
2263        assert_eq!(crush.calls(), (0, 1), "through handle_no_spawn");
2264        // ...and nothing else fires: no confirm prompt, no block, no inject.
2265        let before = crate::runtime::emit_before_tool_call(
2266            &worker,
2267            "bash",
2268            None,
2269            serde_json::json!({"command": "rm -rf /"}),
2270            None,
2271        )
2272        .await;
2273        assert_eq!(before, HookResult::Continue);
2274        let decision = crate::runtime::resolve_before_tool_call_decision(
2275            serde_json::json!({"command": "rm -rf /"}),
2276            before,
2277            None,
2278            false,
2279            None,
2280        )
2281        .await;
2282        assert!(matches!(decision, crate::runtime::BeforeToolCallDecision::Continue { .. }));
2283        for kind in [HookKind::BeforeMessage, HookKind::OnMessageComplete, HookKind::OnSessionStart] {
2284            let mut event = HookEvent::after_tool_call("bash", serde_json::json!({}), "x".into());
2285            event.kind = kind;
2286            assert_eq!(worker.emit(&event).await, HookResult::Continue, "{kind:?}");
2287        }
2288        for probe in &others {
2289            assert_eq!(probe.calls(), (0, 0), "{} never reached from a worker", probe.id);
2290        }
2291        assert_eq!(worker.handler_count().await, 1, "the one transform");
2292        assert!(!worker.is_empty().await);
2293
2294        // The foreground (host bus) is unchanged: observer first, then crush.
2295        assert_eq!(after(&host, "RAW").await, "CRUSHED");
2296        assert_eq!(others[3].calls(), (1, 0));
2297        assert_eq!(crush.calls(), (1, 1));
2298    }
2299
2300    /// Two-key gate on the worker path: an after_tool_call subscriber
2301    /// without `tools.transform_output` is not even dispatched, so it can
2302    /// neither replace nor observe a worker's output.
2303    #[tokio::test]
2304    async fn an_extension_without_transform_output_cannot_replace_a_workers_output() {
2305        let host = Arc::new(HookBus::new());
2306        let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2307        host.subscribe(
2308            HookKind::AfterToolCall,
2309            observer.clone(),
2310            None,
2311            None,
2312            perms(&[Permission::ToolsIntercept]),
2313        )
2314        .await
2315        .unwrap();
2316        let worker = HookBus::worker_transforms(&host);
2317        assert_eq!(after(&worker, "RAW").await, "RAW");
2318        assert_eq!(observer.calls(), (0, 0));
2319        assert!(worker.is_empty().await);
2320        // The foreground still ignores its Replace (the existing gate).
2321        assert_eq!(after(&host, "RAW").await, "RAW");
2322        assert_eq!(observer.calls(), (1, 0));
2323    }
2324
2325    /// Fail-safe: a transform that times out (5 s) or answers `Continue`
2326    /// (what a crashed or not-running extension answers) leaves the
2327    /// original output, and the chain moves on to the next transform.
2328    #[tokio::test(start_paused = true)]
2329    #[serial_test::serial(synaps_base_dir)]
2330    async fn a_timed_out_or_crashed_transform_leaves_the_original_output() {
2331        let _base = crate::test_env::BaseDirGuard::new();
2332        let host = Arc::new(HookBus::new());
2333        let slow = Probe::tagging("slow", "SLOW", Duration::from_secs(60));
2334        host.subscribe(HookKind::AfterToolCall, slow.clone(), None, None, transform())
2335            .await
2336            .unwrap();
2337        let worker = HookBus::worker_transforms(&host);
2338        let started = tokio::time::Instant::now();
2339        assert_eq!(after(&worker, "RAW").await, "RAW", "timed out → original");
2340        let waited = started.elapsed();
2341        assert!(
2342            waited >= HANDLER_TIMEOUT && waited < HANDLER_TIMEOUT + Duration::from_secs(1),
2343            "bounded by the hook timeout: {waited:?}"
2344        );
2345
2346        let crashed = Probe::new("crashed", HookResult::Continue);
2347        let next = Probe::tagging("next", "NEXT", Duration::ZERO);
2348        let host = Arc::new(HookBus::new());
2349        host.subscribe(HookKind::AfterToolCall, crashed.clone(), None, None, transform())
2350            .await
2351            .unwrap();
2352        let worker = HookBus::worker_transforms(&host);
2353        assert_eq!(after(&worker, "RAW").await, "RAW", "crashed → original");
2354        host.subscribe(HookKind::AfterToolCall, next.clone(), None, None, transform())
2355            .await
2356            .unwrap();
2357        assert_eq!(after(&worker, "RAW").await, "NEXT:RAW", "the chain continues");
2358    }
2359
2360    /// Live view, not a snapshot: a worker created before an extension
2361    /// loads, reloads or unloads sees each change on its next call, and
2362    /// never calls a stopped instance.
2363    #[tokio::test]
2364    #[serial_test::serial(synaps_base_dir)]
2365    async fn a_worker_sees_extension_loads_reloads_and_unloads_live() {
2366        let _base = crate::test_env::BaseDirGuard::new();
2367        let host = Arc::new(HookBus::new());
2368        let worker = HookBus::worker_transforms(&host);
2369        assert_eq!(after(&worker, "RAW").await, "RAW", "nothing loaded yet");
2370
2371        let v1 = Probe::tagging("crush", "V1", Duration::ZERO);
2372        host.subscribe(HookKind::AfterToolCall, v1.clone(), None, None, transform())
2373            .await
2374            .unwrap();
2375        assert_eq!(after(&worker, "RAW").await, "V1:RAW", "loaded after the worker started");
2376
2377        // Reload = unsubscribe_all + subscribe the new instance.
2378        host.unsubscribe_all("crush").await;
2379        assert_eq!(after(&worker, "RAW").await, "RAW", "mid-reload: pass-through");
2380        let v2 = Probe::tagging("crush", "V2", Duration::ZERO);
2381        host.subscribe(HookKind::AfterToolCall, v2.clone(), None, None, transform())
2382            .await
2383            .unwrap();
2384        assert_eq!(after(&worker, "RAW").await, "V2:RAW");
2385        assert_eq!(v1.calls(), (0, 1), "the stopped instance is never called again");
2386
2387        host.unsubscribe_all("crush").await;
2388        assert_eq!(after(&worker, "RAW").await, "RAW", "unloaded");
2389    }
2390
2391    /// Parallel workers, each on its own OS thread and current-thread
2392    /// runtime (as subagents run), share one handler and each get the
2393    /// replacement of their own output.
2394    #[test]
2395    #[serial_test::serial(synaps_base_dir)]
2396    fn parallel_workers_each_get_their_own_replacement() {
2397        let _base = crate::test_env::BaseDirGuard::new();
2398        let crush = Probe::tagging("crush", "CRUSHED", Duration::from_millis(2));
2399        let host = Arc::new(HookBus::new());
2400        tokio::runtime::Builder::new_current_thread()
2401            .enable_all()
2402            .build()
2403            .unwrap()
2404            .block_on(host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform()))
2405            .unwrap();
2406        let threads: Vec<_> = (0..8)
2407            .map(|w| {
2408                let worker = HookBus::worker_transforms(&host);
2409                std::thread::spawn(move || {
2410                    let rt = tokio::runtime::Builder::new_current_thread()
2411                        .enable_all()
2412                        .build()
2413                        .unwrap();
2414                    rt.block_on(async {
2415                        for call in 0..10 {
2416                            let output = format!("worker {w} call {call}");
2417                            assert_eq!(after(&worker, &output).await, format!("CRUSHED:{output}"));
2418                        }
2419                    });
2420                })
2421            })
2422            .collect();
2423        for t in threads {
2424            t.join().expect("worker thread");
2425        }
2426        assert_eq!(crush.calls(), (0, 80));
2427    }
2428
2429    #[tokio::test]
2430    async fn a_view_is_read_only_and_a_view_of_a_view_reads_the_host() {
2431        let host = Arc::new(HookBus::new());
2432        let worker = HookBus::worker_transforms(&host);
2433        let probe = Probe::tagging("crush", "C", Duration::ZERO);
2434        let err = worker
2435            .subscribe(HookKind::AfterToolCall, probe.clone(), None, None, transform())
2436            .await
2437            .unwrap_err();
2438        assert!(err.contains("worker"), "{err}");
2439        let nested = HookBus::worker_transforms(&worker);
2440        assert!(nested.is_worker_view());
2441        assert!(Arc::ptr_eq(nested.transforms_of.as_ref().unwrap(), &host));
2442        assert_eq!(nested.handler_count().await, 0);
2443    }
2444}