Skip to main content

meerkat_hooks/
lib.rs

1//! Hook runtimes (in-process, command, HTTP) and deterministic default engine.
2
3// On wasm32, use tokio_with_wasm as a drop-in replacement for tokio.
4#[cfg(target_arch = "wasm32")]
5mod tokio {
6    pub use tokio_with_wasm::alias::*;
7}
8
9// Skill registration
10inventory::submit! {
11    meerkat_skills::SkillRegistration {
12        id: "hook-authoring",
13        name: "Hook Authoring",
14        description: "Writing hooks for the 8 hook points, execution modes, and decision semantics",
15        scope: meerkat_core::skills::SkillScope::Builtin,
16        requires_capabilities: &["hooks"],
17        body: include_str!("../skills/hook-authoring/SKILL.md"),
18        extensions: &[],
19    }
20}
21
22// Capability registration
23inventory::submit! {
24    meerkat_capabilities::CapabilityRegistration {
25        id: meerkat_capabilities::CapabilityId::Hooks,
26        description: "8 hook points, 3 runtimes (in-process/command/HTTP), observe/allow/deny semantics",
27        scope: meerkat_capabilities::CapabilityScope::Universal,
28        requires_feature: None,
29        prerequisites: &[],
30        status_resolver: None,
31    }
32}
33
34use futures::StreamExt;
35use meerkat_core::config::HookAdapterConfig;
36use meerkat_core::time_compat::Duration;
37use meerkat_core::{
38    HookCapability, HookDecision, HookEngine, HookEngineError, HookEntryConfig, HookExecutionMode,
39    HookExecutionReport, HookId, HookInvocation, HookOutcome, HookRunOverrides, HooksConfig,
40};
41use serde::{Deserialize, Serialize};
42use std::collections::{HashMap, HashSet};
43use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46use std::sync::OnceLock;
47use std::sync::atomic::{AtomicU64, Ordering};
48#[cfg(not(target_arch = "wasm32"))]
49use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
50#[cfg(not(target_arch = "wasm32"))]
51use tokio::process::Command;
52use tokio::sync::{Mutex, Semaphore};
53use tokio::time::timeout;
54
55pub use meerkat_core::config::HookInProcessHandlerId as InProcessHookHandlerId;
56
57#[cfg(unix)]
58async fn terminate_child_process_group(child: &mut tokio::process::Child) {
59    use nix::sys::signal::{Signal, killpg};
60    use nix::unistd::Pid;
61
62    if let Some(pid) = child.id() {
63        let pgid = Pid::from_raw(pid as i32);
64        let _ = killpg(pgid, Signal::SIGTERM);
65        tokio::select! {
66            () = tokio::time::sleep(Duration::from_secs(2)) => {
67                let _ = killpg(pgid, Signal::SIGKILL);
68                let _ = child.wait().await;
69            }
70            _ = child.wait() => {}
71        }
72    }
73}
74
75#[cfg(all(not(unix), not(target_arch = "wasm32")))]
76async fn terminate_child_process_group(child: &mut tokio::process::Child) {
77    let _ = child.kill().await;
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81fn remaining_until(deadline: tokio::time::Instant) -> Option<Duration> {
82    let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
83    if remaining.is_zero() {
84        None
85    } else {
86        Some(remaining)
87    }
88}
89
90#[cfg(not(target_arch = "wasm32"))]
91fn abort_command_reader_tasks(
92    stdout_task: &tokio::task::JoinHandle<Result<Vec<u8>, String>>,
93    stderr_task: &tokio::task::JoinHandle<Result<Vec<u8>, String>>,
94) {
95    stdout_task.abort();
96    stderr_task.abort();
97}
98
99/// Response returned by runtime adapters.
100///
101/// `deny_unknown_fields` keeps this contract fail-closed: a runtime that
102/// emits retired vocabulary (e.g. the deleted semantic `patches` machinery)
103/// gets an explicit deserialization error instead of silent acceptance.
104#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105#[serde(rename_all = "snake_case", deny_unknown_fields)]
106pub struct RuntimeHookResponse {
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub decision: Option<HookDecision>,
109}
110
111/// Typed owner for hook execution policy (ordering, timeout, and background
112/// queue-pressure).
113///
114/// Ordering/timeout/queue-pressure used to live as loose fields read directly
115/// off `HooksConfig` inside the engine execution path (remediation row #35).
116/// They are resolved once into this typed owner at engine construction so the
117/// execution path reads a single policy value rather than re-deriving defaults.
118#[derive(Debug, Clone, Copy)]
119pub struct HookExecutionPolicy {
120    default_timeout_ms: u64,
121    payload_max_bytes: usize,
122    background_max_concurrency: usize,
123}
124
125impl HookExecutionPolicy {
126    /// Resolve the typed policy from a [`HooksConfig`], normalizing the
127    /// background concurrency to at least one slot.
128    fn from_config(config: &HooksConfig) -> Self {
129        Self {
130            default_timeout_ms: config.default_timeout_ms,
131            payload_max_bytes: config.payload_max_bytes,
132            background_max_concurrency: config.background_max_concurrency.max(1),
133        }
134    }
135
136    /// Resolve the effective per-invocation timeout for one entry.
137    fn timeout_ms_for(&self, entry: &HookEntryConfig) -> u64 {
138        entry.timeout_ms.unwrap_or(self.default_timeout_ms)
139    }
140
141    /// Maximum serialized payload / response size in bytes.
142    pub fn payload_max_bytes(&self) -> usize {
143        self.payload_max_bytes
144    }
145
146    /// Maximum number of background hook tasks allowed to run concurrently.
147    pub fn background_max_concurrency(&self) -> usize {
148        self.background_max_concurrency
149    }
150}
151
152/// Typed reason a background hook publish was not delivered.
153///
154/// Background hook execution is fire-and-forget by design, but the engine must
155/// not silently lose work: a full queue and a failed/observed-error background
156/// run are both recorded as typed signals against the engine's
157/// [`BackgroundDispatchLedger`] (remediation row #35) instead of a bare
158/// `continue` / `tracing::warn!` with no observable trace.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum BackgroundDispatchSignal {
161    /// The background concurrency queue was full; the hook was not dispatched.
162    Skipped { hook_id: HookId },
163    /// A dispatched background hook failed to execute and its publish (if any)
164    /// was dropped.
165    Dropped { hook_id: HookId, reason: String },
166}
167
168/// Observable ledger of background dispatch skip/drop signals.
169///
170/// Cloned engine handles share the same ledger via the inner `Arc`, so a skip
171/// or drop recorded by a background task is observable through any engine
172/// clone (which is how the cross-task delivery queue is already shared).
173#[derive(Debug, Clone, Default)]
174pub struct BackgroundDispatchLedger {
175    inner: Arc<Mutex<Vec<BackgroundDispatchSignal>>>,
176}
177
178impl BackgroundDispatchLedger {
179    fn new() -> Self {
180        Self::default()
181    }
182
183    async fn record(&self, signal: BackgroundDispatchSignal) {
184        self.inner.lock().await.push(signal);
185    }
186
187    /// Snapshot the recorded signals without clearing them.
188    pub async fn snapshot(&self) -> Vec<BackgroundDispatchSignal> {
189        self.inner.lock().await.clone()
190    }
191
192    /// Drain and return all recorded signals.
193    pub async fn drain(&self) -> Vec<BackgroundDispatchSignal> {
194        std::mem::take(&mut *self.inner.lock().await)
195    }
196}
197
198/// Outcome of registering an in-process hook handler against a stable id.
199///
200/// In-process handler registration used to be a silent `HashMap::insert`
201/// overwrite (remediation row #289), so swapping executable behavior under a
202/// stable id produced no observable signal. Registration now returns this typed
203/// outcome: a fresh id is `Registered`, and re-registering an existing id is a
204/// `Revised` event carrying the new handler revision so the behavior change is
205/// observable rather than silent.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum HandlerRegistrationOutcome {
208    /// The id was previously unregistered; this is a fresh registration.
209    Registered { revision: HookHandlerRevision },
210    /// The id was already registered; the handler was replaced and its
211    /// revision bumped to the carried value.
212    Revised {
213        previous: HookHandlerRevision,
214        revision: HookHandlerRevision,
215    },
216}
217
218/// Monotonic revision of an in-process hook handler registration.
219///
220/// Engine-local observable for handler re-registration (remediation row
221/// #289); unrelated to the deleted semantic hook-patch machinery.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
223pub struct HookHandlerRevision(pub u64);
224
225impl HandlerRegistrationOutcome {
226    /// The handler revision in effect after this registration.
227    pub fn revision(&self) -> HookHandlerRevision {
228        match self {
229            Self::Registered { revision } | Self::Revised { revision, .. } => *revision,
230        }
231    }
232
233    /// Whether this registration replaced an already-registered handler.
234    pub fn is_revision(&self) -> bool {
235        matches!(self, Self::Revised { .. })
236    }
237}
238
239#[cfg(not(target_arch = "wasm32"))]
240type HandlerFuture = Pin<Box<dyn Future<Output = Result<RuntimeHookResponse, String>> + Send>>;
241#[cfg(target_arch = "wasm32")]
242type HandlerFuture = Pin<Box<dyn Future<Output = Result<RuntimeHookResponse, String>>>>;
243
244pub type InProcessHookHandler = Arc<dyn Fn(HookInvocation) -> HandlerFuture + Send + Sync>;
245
246/// Registered in-process handler with its observable revision.
247///
248/// The revision is bumped every time a handler is (re-)registered under an
249/// existing id, so dispatch resolves a canonical, revisioned handler authority
250/// rather than an anonymous map slot that can be silently overwritten.
251struct RegisteredHandler {
252    handler: InProcessHookHandler,
253    revision: HookHandlerRevision,
254}
255
256/// Effective hook entries plus their typed adapters for one run.
257///
258/// Either borrows the engine's construction-time base resolution or owns a
259/// freshly-layered resolution when run overrides are present. Adapters are
260/// resolved at this boundary so the execution path reads typed variants.
261enum ResolvedEntries<'a> {
262    Base {
263        entries: &'a [HookEntryConfig],
264        adapters: &'a HashMap<HookId, HookAdapterConfig>,
265    },
266    Owned {
267        entries: Vec<HookEntryConfig>,
268        adapters: HashMap<HookId, HookAdapterConfig>,
269    },
270}
271
272impl<'a> ResolvedEntries<'a> {
273    fn base(engine: &'a DefaultHookEngine) -> Self {
274        Self::Base {
275            entries: engine.base_entries.as_slice(),
276            adapters: engine.base_adapters.as_ref(),
277        }
278    }
279
280    fn owned(entries: Vec<HookEntryConfig>, adapters: HashMap<HookId, HookAdapterConfig>) -> Self {
281        Self::Owned { entries, adapters }
282    }
283
284    fn entries(&self) -> &[HookEntryConfig] {
285        match self {
286            Self::Base { entries, .. } => entries,
287            Self::Owned { entries, .. } => entries.as_slice(),
288        }
289    }
290
291    fn adapter(&self, hook_id: &HookId) -> Option<&HookAdapterConfig> {
292        match self {
293            Self::Base { adapters, .. } => adapters.get(hook_id),
294            Self::Owned { adapters, .. } => adapters.get(hook_id),
295        }
296    }
297}
298
299/// Deterministic hook engine used by all control surfaces.
300#[derive(Clone)]
301pub struct DefaultHookEngine {
302    base_entries: Arc<Vec<HookEntryConfig>>,
303    /// Typed adapter resolved once per base entry at construction, keyed by
304    /// hook id. The execution path reads the typed variant directly so no
305    /// `serde_json::from_value` runs in `invoke_runtime` (remediation row #231).
306    base_adapters: Arc<HashMap<HookId, HookAdapterConfig>>,
307    base_validation_error: Option<String>,
308    /// Typed execution policy (ordering/timeout/queue-pressure) resolved once
309    /// at construction (remediation row #35).
310    policy: HookExecutionPolicy,
311    http_client: Arc<OnceLock<reqwest::Client>>,
312    in_process_handlers: Arc<std::sync::RwLock<HashMap<InProcessHookHandlerId, RegisteredHandler>>>,
313    /// Typed, observable ledger of background-dispatch skip/drop signals so a
314    /// full queue or a failed background run is never silently lost
315    /// (remediation row #35).
316    background_dispatch_ledger: BackgroundDispatchLedger,
317    background_slots: Arc<Semaphore>,
318    /// In-flight background hook tasks owned by the engine lifecycle.
319    ///
320    /// On non-wasm targets the previously-detached background `tokio::spawn`
321    /// is tracked here so (a) dropping the engine aborts the `JoinSet` instead
322    /// of leaking a zombie task, and (b) `execute` reaps finished tasks before
323    /// dispatching new background work, keeping the set bounded. Wasm has no
324    /// detached-task-teardown hazard, so the field is gated out and the wasm
325    /// path keeps the existing spawn.
326    #[cfg(not(target_arch = "wasm32"))]
327    inflight_background: Arc<Mutex<tokio::task::JoinSet<()>>>,
328    revision: Arc<AtomicU64>,
329}
330
331impl DefaultHookEngine {
332    pub fn new(config: HooksConfig) -> Self {
333        // Validate entries (including registry uniqueness, row #280) and
334        // resolve typed adapters (row #231) once at the construction boundary.
335        // The first failure (entry validation, duplicate id, or malformed
336        // command/HTTP adapter payload) becomes the engine-wide validation
337        // error so every subsequent `execute`/`matching_hooks` call fails
338        // closed with a typed `InvalidConfiguration` rather than re-parsing JSON
339        // on the execution path.
340        let (base_adapters, base_validation_error) = match Self::resolve_base(&config.entries) {
341            // Adapters are the typed `HookAdapterConfig` already deserialized at
342            // the config boundary (row #231), so building the lookup table is
343            // an infallible projection — no JSON re-parse, no failure mode here.
344            Ok(()) => (Self::resolve_adapters(&config.entries), None),
345            // Construction still succeeds but the engine fails closed on
346            // first use; an empty adapter map is never read past the
347            // validation-error short-circuit.
348            Err(err) => (HashMap::new(), Some(err.to_string())),
349        };
350        let policy = HookExecutionPolicy::from_config(&config);
351
352        Self {
353            base_entries: Arc::new(config.entries),
354            base_adapters: Arc::new(base_adapters),
355            base_validation_error,
356            policy,
357            http_client: Arc::new(OnceLock::new()),
358            in_process_handlers: Arc::new(std::sync::RwLock::new(HashMap::new())),
359            background_dispatch_ledger: BackgroundDispatchLedger::new(),
360            background_slots: Arc::new(Semaphore::new(policy.background_max_concurrency())),
361            #[cfg(not(target_arch = "wasm32"))]
362            inflight_background: Arc::new(Mutex::new(tokio::task::JoinSet::new())),
363            revision: Arc::new(AtomicU64::new(1)),
364        }
365    }
366
367    /// Typed, observable view of background-dispatch skip/drop signals.
368    pub fn background_dispatch_ledger(&self) -> &BackgroundDispatchLedger {
369        &self.background_dispatch_ledger
370    }
371
372    /// Validate an entry set: per-entry rules plus registry-level id
373    /// uniqueness (remediation row #280). Duplicate ids are rejected so hook
374    /// identity is registry-owned, not list-ordinal.
375    fn resolve_base(entries: &[HookEntryConfig]) -> Result<(), HookEngineError> {
376        let mut seen: HashSet<&HookId> = HashSet::with_capacity(entries.len());
377        for entry in entries {
378            Self::validate_entry(entry)?;
379            if !seen.insert(&entry.id) {
380                return Err(HookEngineError::InvalidConfiguration(format!(
381                    "duplicate hook id: {}",
382                    entry.id
383                )));
384            }
385        }
386        Ok(())
387    }
388
389    /// Project the typed adapter for every entry into a by-id lookup table.
390    ///
391    /// `entry.runtime` is already the typed [`HookAdapterConfig`] deserialized
392    /// once at the config boundary (row #231), so this is a pure clone-into-map
393    /// projection — no JSON parse, no failure mode.
394    fn resolve_adapters(entries: &[HookEntryConfig]) -> HashMap<HookId, HookAdapterConfig> {
395        entries
396            .iter()
397            .map(|entry| (entry.id.clone(), entry.runtime.clone()))
398            .collect()
399    }
400
401    pub fn with_in_process_handler(
402        self,
403        name: impl Into<InProcessHookHandlerId>,
404        handler: InProcessHookHandler,
405    ) -> Self {
406        let next = self;
407        if let Err(err) = next.insert_in_process_handler(name.into(), handler) {
408            tracing::warn!("failed to register in-process hook handler: {}", err);
409        }
410        next
411    }
412
413    /// Register an in-process hook handler, returning a typed outcome.
414    ///
415    /// Re-registering an already-registered id is a typed `Revised` event that
416    /// bumps the handler revision (remediation row #289), not a silent
417    /// overwrite. A poisoned registry lock fails closed with a typed
418    /// [`HookEngineError`].
419    pub async fn register_in_process_handler(
420        &self,
421        name: impl Into<InProcessHookHandlerId>,
422        handler: InProcessHookHandler,
423    ) -> Result<HandlerRegistrationOutcome, HookEngineError> {
424        self.insert_in_process_handler(name.into(), handler)
425    }
426
427    fn insert_in_process_handler(
428        &self,
429        name: InProcessHookHandlerId,
430        handler: InProcessHookHandler,
431    ) -> Result<HandlerRegistrationOutcome, HookEngineError> {
432        let mut map = self.in_process_handlers.write().map_err(|err| {
433            HookEngineError::InvalidConfiguration(format!(
434                "in-process handler registry lock poisoned: {err}"
435            ))
436        })?;
437        let revision = self.next_revision();
438        match map.get(&name) {
439            Some(existing) => {
440                let previous = existing.revision;
441                map.insert(name, RegisteredHandler { handler, revision });
442                Ok(HandlerRegistrationOutcome::Revised { previous, revision })
443            }
444            None => {
445                map.insert(name, RegisteredHandler { handler, revision });
446                Ok(HandlerRegistrationOutcome::Registered { revision })
447            }
448        }
449    }
450
451    fn next_revision(&self) -> HookHandlerRevision {
452        HookHandlerRevision(self.revision.fetch_add(1, Ordering::SeqCst))
453    }
454
455    #[cfg(not(target_arch = "wasm32"))]
456    async fn read_stream_limited<R>(
457        mut stream: R,
458        byte_limit: usize,
459        stream_name: &str,
460    ) -> Result<Vec<u8>, String>
461    where
462        R: AsyncRead + Unpin,
463    {
464        let mut out = Vec::with_capacity(byte_limit.min(8 * 1024));
465        let mut chunk = [0u8; 8 * 1024];
466
467        loop {
468            let read = stream
469                .read(&mut chunk)
470                .await
471                .map_err(|err| format!("failed reading {stream_name}: {err}"))?;
472            if read == 0 {
473                break;
474            }
475            out.extend_from_slice(&chunk[..read]);
476            if out.len() > byte_limit {
477                return Err(format!(
478                    "{} exceeds max size: {} > {}",
479                    stream_name,
480                    out.len(),
481                    byte_limit
482                ));
483            }
484        }
485
486        Ok(out)
487    }
488
489    /// Resolve the effective entries and their typed adapters for a run.
490    ///
491    /// Without overrides this borrows the construction-time base entries and
492    /// adapter map. With overrides it re-layers the entries, re-validates
493    /// registry uniqueness (row #280), and re-resolves typed adapters
494    /// (row #231) once at this config-layering boundary so the execution path
495    /// never re-parses adapter JSON.
496    fn effective_entries(
497        &self,
498        overrides: Option<&HookRunOverrides>,
499    ) -> Result<ResolvedEntries<'_>, HookEngineError> {
500        if let Some(reason) = &self.base_validation_error {
501            return Err(HookEngineError::InvalidConfiguration(reason.clone()));
502        }
503
504        if let Some(overrides) = overrides {
505            if overrides.disable.is_empty() && overrides.entries.is_empty() {
506                return Ok(ResolvedEntries::base(self));
507            }
508
509            let mut entries = Vec::with_capacity(self.base_entries.len() + overrides.entries.len());
510            if overrides.disable.is_empty() {
511                entries.extend(self.base_entries.iter().cloned());
512            } else {
513                let disabled: HashSet<HookId> = overrides.disable.iter().cloned().collect();
514                entries.extend(
515                    self.base_entries
516                        .iter()
517                        .filter(|entry| !disabled.contains(&entry.id))
518                        .cloned(),
519                );
520            }
521            entries.extend(overrides.entries.clone());
522
523            Self::resolve_base(&entries)?;
524            let adapters = Self::resolve_adapters(&entries);
525
526            return Ok(ResolvedEntries::owned(entries, adapters));
527        }
528
529        Ok(ResolvedEntries::base(self))
530    }
531
532    /// Reap background hook tasks that have already finished.
533    ///
534    /// Non-blocking `try_join_next` (not `join_next().await`) deliberately:
535    /// it removes completed tasks without blocking on still-running ones, so
536    /// it cannot deadlock against a background hook that is itself awaiting
537    /// this same engine. Called before dispatching new background work so the
538    /// `JoinSet` stays bounded and engine drop only has to abort
539    /// genuinely-in-flight work.
540    #[cfg(not(target_arch = "wasm32"))]
541    async fn reap_finished_background_tasks(&self) {
542        let mut set = self.inflight_background.lock().await;
543        while set.try_join_next().is_some() {}
544    }
545
546    fn validate_entry(entry: &HookEntryConfig) -> Result<(), HookEngineError> {
547        if entry.id.0.trim().is_empty() {
548            return Err(HookEngineError::InvalidConfiguration(
549                "hook id cannot be empty".to_string(),
550            ));
551        }
552
553        if entry.mode == HookExecutionMode::Background
554            && entry.capability != HookCapability::Observe
555        {
556            return Err(HookEngineError::InvalidConfiguration(format!(
557                "background hooks must be observe-only: {}",
558                entry.id
559            )));
560        }
561
562        Ok(())
563    }
564
565    async fn execute_one(
566        &self,
567        entry: HookEntryConfig,
568        adapter: HookAdapterConfig,
569        registration_index: usize,
570        invocation: HookInvocation,
571    ) -> Result<HookOutcome, HookEngineError> {
572        let start = meerkat_core::time_compat::Instant::now();
573        let timeout_ms = self.policy.timeout_ms_for(&entry);
574
575        let mut outcome = HookOutcome {
576            hook_id: entry.id.clone(),
577            point: entry.point,
578            priority: entry.priority,
579            registration_index,
580            decision: None,
581            failure_reason: None,
582            duration_ms: None,
583        };
584
585        let response = match &adapter {
586            #[cfg(not(target_arch = "wasm32"))]
587            HookAdapterConfig::Command(cfg) => {
588                self.invoke_command_runtime(
589                    &entry,
590                    &cfg.command,
591                    &cfg.args,
592                    &cfg.env,
593                    invocation.clone(),
594                    timeout_ms,
595                )
596                .await?
597            }
598            #[cfg(target_arch = "wasm32")]
599            HookAdapterConfig::Command(cfg) => {
600                self.invoke_command_runtime(
601                    &entry,
602                    &cfg.command,
603                    &cfg.args,
604                    &cfg.env,
605                    invocation.clone(),
606                    timeout_ms,
607                )
608                .await?
609            }
610            _ => {
611                let runtime_result = timeout(
612                    Duration::from_millis(timeout_ms),
613                    self.invoke_runtime(&entry, &adapter, invocation.clone()),
614                )
615                .await;
616
617                match runtime_result {
618                    Err(_) => {
619                        return Err(HookEngineError::Timeout {
620                            hook_id: entry.id.clone(),
621                            timeout_ms,
622                        });
623                    }
624                    Ok(response) => response?,
625                }
626            }
627        };
628        outcome.decision = runtime_decision_with_configured_hook_id(response.decision, &entry.id);
629
630        if entry.mode == HookExecutionMode::Background {
631            outcome.decision = None;
632        }
633
634        outcome.duration_ms = Some(start.elapsed().as_millis() as u64);
635        Ok(outcome)
636    }
637
638    async fn invoke_runtime(
639        &self,
640        entry: &HookEntryConfig,
641        adapter: &HookAdapterConfig,
642        invocation: HookInvocation,
643    ) -> Result<RuntimeHookResponse, HookEngineError> {
644        // The adapter was parsed once at the config-layering boundary; the
645        // execution path reads the typed variant directly (no
646        // `serde_json::from_value` here — remediation row #231).
647        match adapter {
648            HookAdapterConfig::InProcess(cfg) => {
649                let handler = {
650                    let handlers = self.in_process_handlers.read().map_err(|err| {
651                        HookEngineError::ExecutionFailed {
652                            hook_id: entry.id.clone(),
653                            reason: format!("in-process handler lock poisoned: {err}"),
654                        }
655                    })?;
656                    handlers
657                        .get(&cfg.handler)
658                        .map(|h| h.handler.clone())
659                        .ok_or_else(|| HookEngineError::ExecutionFailed {
660                            hook_id: entry.id.clone(),
661                            reason: format!("in-process handler '{}' not registered", cfg.handler),
662                        })?
663                };
664                (handler)(invocation)
665                    .await
666                    .map_err(|reason| HookEngineError::ExecutionFailed {
667                        hook_id: entry.id.clone(),
668                        reason,
669                    })
670            }
671            HookAdapterConfig::Command(cfg) => Err(HookEngineError::ExecutionFailed {
672                hook_id: entry.id.clone(),
673                reason: format!(
674                    "command hook '{}' was routed outside the subprocess owner",
675                    cfg.command
676                ),
677            }),
678            HookAdapterConfig::Http(cfg) => {
679                self.invoke_http_runtime(entry, &cfg.url, &cfg.method, &cfg.headers, invocation)
680                    .await
681            }
682        }
683    }
684
685    #[cfg(not(target_arch = "wasm32"))]
686    async fn invoke_command_runtime(
687        &self,
688        entry: &HookEntryConfig,
689        command: &str,
690        args: &[String],
691        env: &HashMap<String, String>,
692        invocation: HookInvocation,
693        timeout_ms: u64,
694    ) -> Result<RuntimeHookResponse, HookEngineError> {
695        let payload =
696            serde_json::to_vec(&invocation).map_err(|err| HookEngineError::ExecutionFailed {
697                hook_id: entry.id.clone(),
698                reason: format!("failed to encode invocation payload: {err}"),
699            })?;
700
701        if payload.len() > self.policy.payload_max_bytes() {
702            return Err(HookEngineError::ExecutionFailed {
703                hook_id: entry.id.clone(),
704                reason: format!(
705                    "hook payload exceeds max size: {} > {}",
706                    payload.len(),
707                    self.policy.payload_max_bytes()
708                ),
709            });
710        }
711
712        let mut command = Command::new(command);
713        command
714            .args(args)
715            .envs(env)
716            .stdin(std::process::Stdio::piped())
717            .stdout(std::process::Stdio::piped())
718            .stderr(std::process::Stdio::piped());
719        #[cfg(unix)]
720        command.process_group(0);
721
722        let mut child = command
723            .spawn()
724            .map_err(|err| HookEngineError::ExecutionFailed {
725                hook_id: entry.id.clone(),
726                reason: format!("failed to spawn command hook: {err}"),
727            })?;
728
729        let mut stdin = match child.stdin.take() {
730            Some(stdin) => stdin,
731            None => {
732                terminate_child_process_group(&mut child).await;
733                return Err(HookEngineError::ExecutionFailed {
734                    hook_id: entry.id.clone(),
735                    reason: "command hook stdin pipe unavailable".to_string(),
736                });
737            }
738        };
739        let stdout = match child.stdout.take() {
740            Some(stdout) => stdout,
741            None => {
742                terminate_child_process_group(&mut child).await;
743                return Err(HookEngineError::ExecutionFailed {
744                    hook_id: entry.id.clone(),
745                    reason: "command hook stdout pipe unavailable".to_string(),
746                });
747            }
748        };
749        let stderr = match child.stderr.take() {
750            Some(stderr) => stderr,
751            None => {
752                terminate_child_process_group(&mut child).await;
753                return Err(HookEngineError::ExecutionFailed {
754                    hook_id: entry.id.clone(),
755                    reason: "command hook stderr pipe unavailable".to_string(),
756                });
757            }
758        };
759
760        let max_output_bytes = self.policy.payload_max_bytes();
761        let mut stdout_task = tokio::spawn(Self::read_stream_limited(
762            stdout,
763            max_output_bytes,
764            "command stdout",
765        ));
766        let mut stderr_task = tokio::spawn(Self::read_stream_limited(
767            stderr,
768            max_output_bytes,
769            "command stderr",
770        ));
771
772        let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
773        let Some(write_timeout) = remaining_until(deadline) else {
774            terminate_child_process_group(&mut child).await;
775            abort_command_reader_tasks(&stdout_task, &stderr_task);
776            return Err(HookEngineError::Timeout {
777                hook_id: entry.id.clone(),
778                timeout_ms,
779            });
780        };
781        tokio::select! {
782            write_result = stdin.write_all(&payload) => {
783                if let Err(err) = write_result {
784                    terminate_child_process_group(&mut child).await;
785                    let _ = stdout_task.await;
786                    let _ = stderr_task.await;
787                    return Err(HookEngineError::ExecutionFailed {
788                        hook_id: entry.id.clone(),
789                        reason: format!("failed to write command hook stdin: {err}"),
790                    });
791                }
792            }
793            () = tokio::time::sleep(write_timeout) => {
794                terminate_child_process_group(&mut child).await;
795                abort_command_reader_tasks(&stdout_task, &stderr_task);
796                return Err(HookEngineError::Timeout {
797                    hook_id: entry.id.clone(),
798                    timeout_ms,
799                });
800            }
801        }
802        drop(stdin);
803
804        let Some(wait_timeout) = remaining_until(deadline) else {
805            terminate_child_process_group(&mut child).await;
806            abort_command_reader_tasks(&stdout_task, &stderr_task);
807            return Err(HookEngineError::Timeout {
808                hook_id: entry.id.clone(),
809                timeout_ms,
810            });
811        };
812        let status = tokio::select! {
813            status = child.wait() => status.map_err(|err| HookEngineError::ExecutionFailed {
814                hook_id: entry.id.clone(),
815                reason: format!("failed waiting for command hook: {err}"),
816            })?,
817            () = tokio::time::sleep(wait_timeout) => {
818                terminate_child_process_group(&mut child).await;
819                abort_command_reader_tasks(&stdout_task, &stderr_task);
820                return Err(HookEngineError::Timeout {
821                    hook_id: entry.id.clone(),
822                    timeout_ms,
823                });
824            }
825        };
826
827        let Some(stdout_timeout) = remaining_until(deadline) else {
828            abort_command_reader_tasks(&stdout_task, &stderr_task);
829            return Err(HookEngineError::Timeout {
830                hook_id: entry.id.clone(),
831                timeout_ms,
832            });
833        };
834        let stdout = tokio::select! {
835            stdout = &mut stdout_task => stdout,
836            () = tokio::time::sleep(stdout_timeout) => {
837                abort_command_reader_tasks(&stdout_task, &stderr_task);
838                return Err(HookEngineError::Timeout {
839                    hook_id: entry.id.clone(),
840                    timeout_ms,
841                });
842            }
843        }
844        .map_err(|err| HookEngineError::ExecutionFailed {
845            hook_id: entry.id.clone(),
846            reason: format!("command stdout task join failed: {err}"),
847        })?
848        .map_err(|reason| HookEngineError::ExecutionFailed {
849            hook_id: entry.id.clone(),
850            reason,
851        })?;
852        let Some(stderr_timeout) = remaining_until(deadline) else {
853            stderr_task.abort();
854            return Err(HookEngineError::Timeout {
855                hook_id: entry.id.clone(),
856                timeout_ms,
857            });
858        };
859        let stderr = tokio::select! {
860            stderr = &mut stderr_task => stderr,
861            () = tokio::time::sleep(stderr_timeout) => {
862                stderr_task.abort();
863                return Err(HookEngineError::Timeout {
864                    hook_id: entry.id.clone(),
865                    timeout_ms,
866                });
867            }
868        }
869        .map_err(|err| HookEngineError::ExecutionFailed {
870            hook_id: entry.id.clone(),
871            reason: format!("command stderr task join failed: {err}"),
872        })?
873        .map_err(|reason| HookEngineError::ExecutionFailed {
874            hook_id: entry.id.clone(),
875            reason,
876        })?;
877
878        if !status.success() {
879            let stderr = String::from_utf8_lossy(&stderr).to_string();
880            return Err(HookEngineError::ExecutionFailed {
881                hook_id: entry.id.clone(),
882                reason: format!("command hook exited with {status}: {stderr}"),
883            });
884        }
885
886        serde_json::from_slice::<RuntimeHookResponse>(&stdout).map_err(|err| {
887            HookEngineError::ExecutionFailed {
888                hook_id: entry.id.clone(),
889                reason: format!("invalid command hook response: {err}"),
890            }
891        })
892    }
893
894    #[cfg(target_arch = "wasm32")]
895    async fn invoke_command_runtime(
896        &self,
897        entry: &HookEntryConfig,
898        _command: &str,
899        _args: &[String],
900        _env: &HashMap<String, String>,
901        _invocation: HookInvocation,
902        _timeout_ms: u64,
903    ) -> Result<RuntimeHookResponse, HookEngineError> {
904        Err(HookEngineError::ExecutionFailed {
905            hook_id: entry.id.clone(),
906            reason: "command hooks are not supported on wasm32".to_string(),
907        })
908    }
909
910    async fn invoke_http_runtime(
911        &self,
912        entry: &HookEntryConfig,
913        url: &str,
914        method: &str,
915        headers: &HashMap<String, String>,
916        invocation: HookInvocation,
917    ) -> Result<RuntimeHookResponse, HookEngineError> {
918        let payload =
919            serde_json::to_vec(&invocation).map_err(|err| HookEngineError::ExecutionFailed {
920                hook_id: entry.id.clone(),
921                reason: format!("failed to encode invocation payload: {err}"),
922            })?;
923
924        if payload.len() > self.policy.payload_max_bytes() {
925            return Err(HookEngineError::ExecutionFailed {
926                hook_id: entry.id.clone(),
927                reason: format!(
928                    "hook payload exceeds max size: {} > {}",
929                    payload.len(),
930                    self.policy.payload_max_bytes()
931                ),
932            });
933        }
934
935        let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|err| {
936            HookEngineError::ExecutionFailed {
937                hook_id: entry.id.clone(),
938                reason: format!("invalid HTTP method '{method}': {err}"),
939            }
940        })?;
941
942        let http_client = self.http_client.get_or_init(reqwest::Client::new);
943        let mut req = http_client
944            .request(method, url)
945            .header("content-type", "application/json")
946            .body(payload);
947
948        for (name, value) in headers {
949            req = req.header(name, value);
950        }
951
952        let response = req
953            .send()
954            .await
955            .map_err(|err| HookEngineError::ExecutionFailed {
956                hook_id: entry.id.clone(),
957                reason: format!("HTTP hook request failed: {err}"),
958            })?;
959
960        let status = response.status();
961        let mut bytes = Vec::new();
962        let mut stream = response.bytes_stream();
963        while let Some(chunk) = stream.next().await {
964            let chunk = chunk.map_err(|err| HookEngineError::ExecutionFailed {
965                hook_id: entry.id.clone(),
966                reason: format!("failed reading HTTP hook response body: {err}"),
967            })?;
968            bytes.extend_from_slice(&chunk);
969            if bytes.len() > self.policy.payload_max_bytes() {
970                return Err(HookEngineError::ExecutionFailed {
971                    hook_id: entry.id.clone(),
972                    reason: format!(
973                        "HTTP hook response exceeds max size: {} > {}",
974                        bytes.len(),
975                        self.policy.payload_max_bytes()
976                    ),
977                });
978            }
979        }
980
981        if !status.is_success() {
982            let body = String::from_utf8_lossy(&bytes);
983            return Err(HookEngineError::ExecutionFailed {
984                hook_id: entry.id.clone(),
985                reason: format!("HTTP hook returned {status}: {body}"),
986            });
987        }
988
989        serde_json::from_slice::<RuntimeHookResponse>(&bytes).map_err(|err| {
990            HookEngineError::ExecutionFailed {
991                hook_id: entry.id.clone(),
992                reason: format!("invalid HTTP hook response: {err}"),
993            }
994        })
995    }
996}
997
998fn runtime_decision_with_configured_hook_id(
999    decision: Option<HookDecision>,
1000    hook_id: &HookId,
1001) -> Option<HookDecision> {
1002    match decision {
1003        Some(HookDecision::Deny {
1004            reason_code,
1005            message,
1006            payload,
1007            ..
1008        }) => Some(HookDecision::Deny {
1009            hook_id: hook_id.clone(),
1010            reason_code,
1011            message,
1012            payload,
1013        }),
1014        decision => decision,
1015    }
1016}
1017
1018#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
1019#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
1020impl HookEngine for DefaultHookEngine {
1021    fn matching_hooks(
1022        &self,
1023        invocation: &HookInvocation,
1024        overrides: Option<&HookRunOverrides>,
1025    ) -> Result<Vec<HookId>, HookEngineError> {
1026        let entries = self.effective_entries(overrides)?;
1027        // Registry uniqueness is enforced at resolution, so each id is
1028        // canonical here — one match per id (remediation row #280).
1029        Ok(entries
1030            .entries()
1031            .iter()
1032            .filter(|entry| entry.enabled && entry.point == invocation.point)
1033            .map(|entry| entry.id.clone())
1034            .collect())
1035    }
1036
1037    async fn execute(
1038        &self,
1039        invocation: HookInvocation,
1040        overrides: Option<&HookRunOverrides>,
1041    ) -> Result<HookExecutionReport, HookEngineError> {
1042        let mut foreground: Vec<(usize, HookEntryConfig, HookAdapterConfig)> = Vec::new();
1043        let mut background: Vec<(usize, HookEntryConfig, HookAdapterConfig)> = Vec::new();
1044        let resolved = self.effective_entries(overrides)?;
1045        for (registration_index, entry) in resolved
1046            .entries()
1047            .iter()
1048            .filter(|entry| entry.enabled && entry.point == invocation.point)
1049            .cloned()
1050            .enumerate()
1051        {
1052            // Every resolved entry has a typed adapter (resolved at the config
1053            // boundary). A missing adapter is a pre-execution config-resolution
1054            // invariant break (the hook never starts), so fail closed with an
1055            // InvalidConfiguration error rather than ExecutionFailed. This keeps
1056            // the rule "a HookEngineError carrying a hook_id() means that hook
1057            // actually began executing" — Timeout and adapter-runtime
1058            // ExecutionFailed both originate from execute_one after the hook
1059            // started — which Agent::execute_hooks relies on to emit a
1060            // HookStarted before the terminal HookFailed on the error path.
1061            let adapter = resolved.adapter(&entry.id).cloned().ok_or_else(|| {
1062                HookEngineError::InvalidConfiguration(format!(
1063                    "no resolved adapter for hook id '{}'",
1064                    entry.id
1065                ))
1066            })?;
1067            if entry.mode == HookExecutionMode::Background {
1068                background.push((registration_index, entry, adapter));
1069            } else {
1070                foreground.push((registration_index, entry, adapter));
1071            }
1072        }
1073        drop(resolved);
1074
1075        if foreground.is_empty() && background.is_empty() {
1076            return Ok(HookExecutionReport::empty());
1077        }
1078
1079        foreground.sort_by(|(a_idx, a_entry, _), (b_idx, b_entry, _)| {
1080            a_entry
1081                .priority
1082                .cmp(&b_entry.priority)
1083                .then_with(|| a_idx.cmp(b_idx))
1084        });
1085
1086        let mut merged = HookExecutionReport::empty();
1087        for (registration_index, entry, adapter) in foreground {
1088            // Record the start before running: a foreground hook that actually
1089            // executes is the only one that earns a `HookStarted` event. A deny
1090            // short-circuit breaks the loop below, so later foreground entries
1091            // are never pushed here.
1092            merged.started.push(entry.id.clone());
1093            let outcome = self
1094                .execute_one(entry, adapter, registration_index, invocation.clone())
1095                .await?;
1096
1097            if let Some(decision) = outcome.decision.clone() {
1098                match &decision {
1099                    HookDecision::Deny { .. } => {
1100                        merged.decision = Some(decision);
1101                    }
1102                    HookDecision::Allow => {
1103                        if !matches!(merged.decision, Some(HookDecision::Deny { .. })) {
1104                            merged.decision = Some(HookDecision::Allow);
1105                        }
1106                    }
1107                }
1108            }
1109            let should_stop = matches!(outcome.decision, Some(HookDecision::Deny { .. }));
1110            merged.outcomes.push(outcome);
1111            if should_stop {
1112                break;
1113            }
1114        }
1115
1116        if !matches!(merged.decision, Some(HookDecision::Deny { .. })) {
1117            #[cfg(not(target_arch = "wasm32"))]
1118            if !background.is_empty() {
1119                self.reap_finished_background_tasks().await;
1120            }
1121            for (registration_index, entry, adapter) in background {
1122                let permit = match self.background_slots.clone().try_acquire_owned() {
1123                    Ok(permit) => permit,
1124                    Err(_) => {
1125                        // Queue full: record a typed skip signal against the
1126                        // observable ledger rather than a silent `continue`
1127                        // (remediation row #35).
1128                        self.background_dispatch_ledger
1129                            .record(BackgroundDispatchSignal::Skipped {
1130                                hook_id: entry.id.clone(),
1131                            })
1132                            .await;
1133                        tracing::warn!("background hook queue full, skipping {}", entry.id);
1134                        continue;
1135                    }
1136                };
1137                // A permit was acquired and the task is about to be spawned, so
1138                // this background hook truly begins executing — record its start.
1139                // The saturated `continue` branch above never reaches here.
1140                merged.started.push(entry.id.clone());
1141                let engine = self.clone();
1142                let invocation_cloned = invocation.clone();
1143                let task = async move {
1144                    let _permit = permit;
1145                    let hook_id = entry.id.clone();
1146                    let outcome = match engine
1147                        .execute_one(entry, adapter, registration_index, invocation_cloned)
1148                        .await
1149                    {
1150                        Ok(outcome) => outcome,
1151                        Err(error) => {
1152                            // A failed background run drops its publish; record
1153                            // a typed dropped signal so the loss is observable
1154                            // (remediation row #35).
1155                            let reason = error.to_string();
1156                            engine
1157                                .background_dispatch_ledger
1158                                .record(BackgroundDispatchSignal::Dropped {
1159                                    hook_id,
1160                                    reason: reason.clone(),
1161                                })
1162                                .await;
1163                            tracing::warn!(
1164                                error = %reason,
1165                                "background hook execution failed"
1166                            );
1167                            return;
1168                        }
1169                    };
1170                    if let Some(failure_reason) = &outcome.failure_reason {
1171                        // A produced-error background outcome drops its publish;
1172                        // record a typed dropped signal (remediation row #35).
1173                        // The ledger carries the derived display string.
1174                        let reason = failure_reason.to_string();
1175                        engine
1176                            .background_dispatch_ledger
1177                            .record(BackgroundDispatchSignal::Dropped {
1178                                hook_id: outcome.hook_id.clone(),
1179                                reason: reason.clone(),
1180                            })
1181                            .await;
1182                        tracing::warn!(
1183                            hook_id = %outcome.hook_id,
1184                            point = ?outcome.point,
1185                            error = %reason,
1186                            "background hook execution produced an error"
1187                        );
1188                    }
1189                };
1190                // Non-wasm: bind the task to the engine lifecycle via the owned
1191                // `JoinSet` so engine drop aborts it and `execute` reaps
1192                // finished tasks. Wasm: keep the existing detached spawn (no
1193                // detached-task-teardown hazard there).
1194                #[cfg(not(target_arch = "wasm32"))]
1195                self.inflight_background.lock().await.spawn(task);
1196                #[cfg(target_arch = "wasm32")]
1197                tokio::spawn(task);
1198            }
1199        }
1200
1201        Ok(merged)
1202    }
1203}
1204
1205#[cfg(test)]
1206#[allow(
1207    clippy::expect_used,
1208    clippy::field_reassign_with_default,
1209    clippy::panic,
1210    clippy::unwrap_used
1211)]
1212mod tests {
1213    use super::*;
1214    use meerkat_core::config::HookRuntimeKind;
1215    use meerkat_core::{
1216        ContentInput, HookLlmRequest, HookPoint, HookReasonCode, RunInput, SessionId,
1217    };
1218    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
1219
1220    fn static_handler(response: RuntimeHookResponse) -> InProcessHookHandler {
1221        Arc::new(move |_invocation| {
1222            let response = response.clone();
1223            Box::pin(async move { Ok(response) })
1224        })
1225    }
1226
1227    fn delayed_handler(delay_ms: u64, response: RuntimeHookResponse) -> InProcessHookHandler {
1228        Arc::new(move |_invocation| {
1229            let response = response.clone();
1230            Box::pin(async move {
1231                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
1232                Ok(response)
1233            })
1234        })
1235    }
1236
1237    fn runtime_in_process(name: &str) -> HookAdapterConfig {
1238        HookAdapterConfig::in_process(name)
1239    }
1240
1241    #[test]
1242    fn in_process_handler_id_preserves_string_wire_shape() {
1243        let id: InProcessHookHandlerId = serde_json::from_value(serde_json::json!("handler-a"))
1244            .expect("handler id should deserialize from string");
1245
1246        assert_eq!(id.as_str(), "handler-a");
1247        assert_eq!(
1248            serde_json::to_value(&id).expect("handler id should serialize"),
1249            serde_json::json!("handler-a")
1250        );
1251    }
1252
1253    #[tokio::test]
1254    async fn deterministic_merge_by_priority_then_registration() {
1255        let mut config = HooksConfig::default();
1256        config.entries = vec![
1257            HookEntryConfig {
1258                id: HookId::new("hook-a"),
1259                point: HookPoint::PreLlmRequest,
1260                priority: 10,
1261                runtime: runtime_in_process("a"),
1262                capability: HookCapability::Observe,
1263                ..Default::default()
1264            },
1265            HookEntryConfig {
1266                id: HookId::new("hook-b"),
1267                point: HookPoint::PreLlmRequest,
1268                priority: 5,
1269                runtime: runtime_in_process("b"),
1270                capability: HookCapability::Guardrail,
1271                ..Default::default()
1272            },
1273        ];
1274
1275        let engine = DefaultHookEngine::new(config);
1276        engine
1277            .register_in_process_handler(
1278                "a",
1279                static_handler(RuntimeHookResponse { decision: None }),
1280            )
1281            .await
1282            .expect("in-process handler registration must succeed");
1283        engine
1284            .register_in_process_handler(
1285                "b",
1286                static_handler(RuntimeHookResponse {
1287                    decision: Some(HookDecision::Allow),
1288                }),
1289            )
1290            .await
1291            .expect("in-process handler registration must succeed");
1292
1293        let report = engine
1294            .execute(
1295                HookInvocation {
1296                    point: HookPoint::PreLlmRequest,
1297                    session_id: SessionId::new(),
1298                    turn_number: Some(1),
1299                    prompt_input: None,
1300                    error_report: None,
1301                    error_class: None,
1302                    llm_request: Some(HookLlmRequest {
1303                        max_tokens: 256,
1304                        temperature: None,
1305                        provider_params: None,
1306                        message_count: 1,
1307                    }),
1308                    llm_response: None,
1309                    tool_call: None,
1310                    tool_result: None,
1311                },
1312                None,
1313            )
1314            .await
1315            .unwrap();
1316
1317        let hook_ids: Vec<_> = report
1318            .outcomes
1319            .iter()
1320            .map(|outcome| &outcome.hook_id)
1321            .collect();
1322        assert_eq!(
1323            hook_ids,
1324            vec![&HookId::new("hook-b"), &HookId::new("hook-a")]
1325        );
1326        assert!(matches!(report.decision, Some(HookDecision::Allow)));
1327    }
1328
1329    #[tokio::test]
1330    async fn background_hooks_are_observation_only_and_publish_no_patches() {
1331        let mut config = HooksConfig::default();
1332        config.entries = vec![HookEntryConfig {
1333            id: HookId::new("hook-post-bg"),
1334            point: HookPoint::PostToolExecution,
1335            mode: HookExecutionMode::Background,
1336            capability: HookCapability::Observe,
1337            runtime: runtime_in_process("post-bg"),
1338            ..Default::default()
1339        }];
1340
1341        let engine = DefaultHookEngine::new(config);
1342        engine
1343            .register_in_process_handler(
1344                "post-bg",
1345                static_handler(RuntimeHookResponse { decision: None }),
1346            )
1347            .await
1348            .expect("in-process handler registration must succeed");
1349
1350        let session_id = SessionId::new();
1351        let report = engine
1352            .execute(
1353                HookInvocation {
1354                    point: HookPoint::PostToolExecution,
1355                    session_id: session_id.clone(),
1356                    turn_number: Some(1),
1357                    prompt_input: None,
1358                    error_report: None,
1359                    error_class: None,
1360                    llm_request: None,
1361                    llm_response: None,
1362                    tool_call: None,
1363                    tool_result: None,
1364                },
1365                None,
1366            )
1367            .await
1368            .unwrap();
1369
1370        tokio::time::sleep(Duration::from_millis(10)).await;
1371        assert!(
1372            report
1373                .outcomes
1374                .iter()
1375                .all(|outcome| outcome.decision.is_none()),
1376            "background hooks are observation-only"
1377        );
1378    }
1379
1380    #[tokio::test]
1381    async fn background_post_hook_task_is_tracked_and_reaped_not_leaked() {
1382        // R2 regression: the background post-hook task used to be a detached
1383        // `tokio::spawn` tied to nothing, so on engine/runtime teardown an
1384        // in-flight task would vanish with no signal. The task is now owned by
1385        // the engine's `JoinSet`, so it is (a) observable as in-flight after
1386        // `execute()` and (b) reaped by `reap_finished_background_tasks` once
1387        // it completes.
1388        let mut config = HooksConfig::default();
1389        config.entries = vec![HookEntryConfig {
1390            id: HookId::new("tracked-post-bg"),
1391            point: HookPoint::PostToolExecution,
1392            mode: HookExecutionMode::Background,
1393            capability: HookCapability::Observe,
1394            runtime: runtime_in_process("tracked-post-bg"),
1395            ..Default::default()
1396        }];
1397
1398        let engine = DefaultHookEngine::new(config);
1399        // A handler with a short delay so the spawned task is genuinely
1400        // in-flight when we observe the JoinSet immediately after `execute()`.
1401        engine
1402            .register_in_process_handler(
1403                "tracked-post-bg",
1404                delayed_handler(20, RuntimeHookResponse { decision: None }),
1405            )
1406            .await
1407            .expect("in-process handler registration must succeed");
1408
1409        let session_id = SessionId::new();
1410        let report = engine
1411            .execute(
1412                HookInvocation {
1413                    point: HookPoint::PostToolExecution,
1414                    session_id: session_id.clone(),
1415                    turn_number: Some(1),
1416                    prompt_input: None,
1417                    error_report: None,
1418                    error_class: None,
1419                    llm_request: None,
1420                    llm_response: None,
1421                    tool_call: None,
1422                    tool_result: None,
1423                },
1424                None,
1425            )
1426            .await
1427            .unwrap();
1428        assert!(
1429            report.outcomes.is_empty(),
1430            "background outcomes are not merged"
1431        );
1432
1433        // The background task is owned by the engine's JoinSet, not detached:
1434        // it is tracked as in-flight right after `execute()` returns.
1435        assert_eq!(
1436            engine.inflight_background.lock().await.len(),
1437            1,
1438            "background post-hook task must be tracked in the engine JoinSet, not detached"
1439        );
1440
1441        // The reap seam removes the finished task (non-blocking
1442        // `try_join_next`). Reap repeatedly until the task has run and been
1443        // reaped, proving the task is lifecycle-bound rather than leaked.
1444        for _ in 0..200 {
1445            engine.reap_finished_background_tasks().await;
1446            if engine.inflight_background.lock().await.is_empty() {
1447                break;
1448            }
1449            tokio::time::sleep(Duration::from_millis(5)).await;
1450        }
1451        assert!(
1452            engine.inflight_background.lock().await.is_empty(),
1453            "finished background task must be reaped from the engine JoinSet, not leaked"
1454        );
1455    }
1456
1457    #[tokio::test]
1458    async fn legacy_semantic_patch_payload_from_command_runtime_fails_closed() {
1459        let mut config = HooksConfig::default();
1460        config.entries = vec![HookEntryConfig {
1461            id: HookId::new("legacy-patch-command"),
1462            point: HookPoint::PreLlmRequest,
1463            capability: HookCapability::Guardrail,
1464            runtime: HookAdapterConfig::from_kind_and_value(
1465                HookRuntimeKind::Command,
1466                Some(serde_json::json!({
1467                    "command": "sh",
1468                    "args": [
1469                        "-c",
1470                        "cat >/dev/null; printf '%s' '{\"patches\":[{\"patch_type\":\"llm_request\",\"max_tokens\":1}]}'"
1471                    ],
1472                    "env": {}
1473                })),
1474            )
1475            .unwrap_or_default(),
1476            ..Default::default()
1477        }];
1478
1479        let engine = DefaultHookEngine::new(config);
1480        let err = engine
1481            .execute(
1482                HookInvocation {
1483                    point: HookPoint::PreLlmRequest,
1484                    session_id: SessionId::new(),
1485                    turn_number: Some(1),
1486                    prompt_input: None,
1487                    error_report: None,
1488                    error_class: None,
1489                    llm_request: Some(HookLlmRequest {
1490                        max_tokens: 256,
1491                        temperature: None,
1492                        provider_params: None,
1493                        message_count: 1,
1494                    }),
1495                    llm_response: None,
1496                    tool_call: None,
1497                    tool_result: None,
1498                },
1499                None,
1500            )
1501            .await
1502            .expect_err("legacy semantic patch payloads must fail closed");
1503
1504        assert!(matches!(
1505            err,
1506            HookEngineError::ExecutionFailed { ref hook_id, .. }
1507                if hook_id == &HookId::new("legacy-patch-command")
1508        ));
1509    }
1510
1511    #[tokio::test]
1512    async fn deterministic_merge_ignores_completion_order() {
1513        let mut config = HooksConfig::default();
1514        config.entries = vec![
1515            HookEntryConfig {
1516                id: HookId::new("slow-low-priority"),
1517                point: HookPoint::PreLlmRequest,
1518                priority: 100,
1519                runtime: runtime_in_process("slow"),
1520                capability: HookCapability::Observe,
1521                ..Default::default()
1522            },
1523            HookEntryConfig {
1524                id: HookId::new("fast-high-priority"),
1525                point: HookPoint::PreLlmRequest,
1526                priority: 1,
1527                runtime: runtime_in_process("fast"),
1528                capability: HookCapability::Observe,
1529                ..Default::default()
1530            },
1531        ];
1532
1533        let engine = DefaultHookEngine::new(config);
1534        engine
1535            .register_in_process_handler(
1536                "slow",
1537                delayed_handler(100, RuntimeHookResponse { decision: None }),
1538            )
1539            .await
1540            .expect("in-process handler registration must succeed");
1541        engine
1542            .register_in_process_handler(
1543                "fast",
1544                delayed_handler(1, RuntimeHookResponse { decision: None }),
1545            )
1546            .await
1547            .expect("in-process handler registration must succeed");
1548
1549        let report = engine
1550            .execute(
1551                HookInvocation {
1552                    point: HookPoint::PreLlmRequest,
1553                    session_id: SessionId::new(),
1554                    turn_number: Some(1),
1555                    prompt_input: None,
1556                    error_report: None,
1557                    error_class: None,
1558                    llm_request: Some(HookLlmRequest {
1559                        max_tokens: 256,
1560                        temperature: None,
1561                        provider_params: None,
1562                        message_count: 1,
1563                    }),
1564                    llm_response: None,
1565                    tool_call: None,
1566                    tool_result: None,
1567                },
1568                None,
1569            )
1570            .await
1571            .unwrap();
1572
1573        let hook_ids: Vec<_> = report
1574            .outcomes
1575            .iter()
1576            .map(|outcome| &outcome.hook_id)
1577            .collect();
1578        assert_eq!(
1579            hook_ids,
1580            vec![
1581                &HookId::new("fast-high-priority"),
1582                &HookId::new("slow-low-priority")
1583            ]
1584        );
1585    }
1586
1587    #[tokio::test]
1588    async fn observe_runtime_error_returns_typed_engine_failure() {
1589        let mut config = HooksConfig::default();
1590        config.entries = vec![HookEntryConfig {
1591            id: HookId::new("observe-missing-handler"),
1592            point: HookPoint::PreToolExecution,
1593            capability: HookCapability::Observe,
1594            runtime: runtime_in_process("missing"),
1595            ..Default::default()
1596        }];
1597
1598        let engine = DefaultHookEngine::new(config);
1599        let err = engine
1600            .execute(
1601                HookInvocation {
1602                    point: HookPoint::PreToolExecution,
1603                    session_id: SessionId::new(),
1604                    turn_number: Some(1),
1605                    prompt_input: None,
1606                    error_report: None,
1607                    error_class: None,
1608                    llm_request: None,
1609                    llm_response: None,
1610                    tool_call: None,
1611                    tool_result: None,
1612                },
1613                None,
1614            )
1615            .await
1616            .expect_err("hook runtime errors must fail closed through typed engine errors");
1617
1618        assert!(matches!(
1619            err,
1620            HookEngineError::ExecutionFailed { ref hook_id, .. }
1621                if hook_id == &HookId::new("observe-missing-handler")
1622        ));
1623    }
1624
1625    #[tokio::test]
1626    async fn guardrail_runtime_error_returns_typed_engine_failure() {
1627        let mut config = HooksConfig::default();
1628        config.entries = vec![HookEntryConfig {
1629            id: HookId::new("guardrail-missing-handler"),
1630            point: HookPoint::PreToolExecution,
1631            capability: HookCapability::Guardrail,
1632            runtime: runtime_in_process("missing"),
1633            ..Default::default()
1634        }];
1635
1636        let engine = DefaultHookEngine::new(config);
1637        let err = engine
1638            .execute(
1639                HookInvocation {
1640                    point: HookPoint::PreToolExecution,
1641                    session_id: SessionId::new(),
1642                    turn_number: Some(1),
1643                    prompt_input: None,
1644                    error_report: None,
1645                    error_class: None,
1646                    llm_request: None,
1647                    llm_response: None,
1648                    tool_call: None,
1649                    tool_result: None,
1650                },
1651                None,
1652            )
1653            .await
1654            .expect_err("hook runtime errors must not be converted into hook-local denials");
1655
1656        assert!(matches!(
1657            err,
1658            HookEngineError::ExecutionFailed { ref hook_id, .. }
1659                if hook_id == &HookId::new("guardrail-missing-handler")
1660        ));
1661    }
1662
1663    #[tokio::test]
1664    async fn deny_short_circuits_lower_priority_hooks() {
1665        let mut config = HooksConfig::default();
1666        config.entries = vec![
1667            HookEntryConfig {
1668                id: HookId::new("guardrail-deny"),
1669                point: HookPoint::PreToolExecution,
1670                priority: 1,
1671                capability: HookCapability::Guardrail,
1672                runtime: runtime_in_process("guardrail"),
1673                ..Default::default()
1674            },
1675            HookEntryConfig {
1676                id: HookId::new("observer-late"),
1677                point: HookPoint::PreToolExecution,
1678                priority: 100,
1679                capability: HookCapability::Observe,
1680                runtime: runtime_in_process("observer"),
1681                ..Default::default()
1682            },
1683        ];
1684
1685        let observed_runs = Arc::new(AtomicUsize::new(0));
1686        let observer_runs = observed_runs.clone();
1687
1688        let engine = DefaultHookEngine::new(config);
1689        engine
1690            .register_in_process_handler(
1691                "guardrail",
1692                static_handler(RuntimeHookResponse {
1693                    decision: Some(HookDecision::deny(
1694                        HookId::new("guardrail-deny"),
1695                        HookReasonCode::PolicyViolation,
1696                        "deny",
1697                        None,
1698                    )),
1699                }),
1700            )
1701            .await
1702            .expect("in-process handler registration must succeed");
1703        engine
1704            .register_in_process_handler(
1705                "observer",
1706                Arc::new(move |_invocation| {
1707                    let observer_runs = observer_runs.clone();
1708                    Box::pin(async move {
1709                        observer_runs.fetch_add(1, AtomicOrdering::SeqCst);
1710                        Ok(RuntimeHookResponse {
1711                            decision: Some(HookDecision::Allow),
1712                        })
1713                    })
1714                }),
1715            )
1716            .await
1717            .expect("in-process handler registration must succeed");
1718
1719        let report = engine
1720            .execute(
1721                HookInvocation {
1722                    point: HookPoint::PreToolExecution,
1723                    session_id: SessionId::new(),
1724                    turn_number: Some(1),
1725                    prompt_input: None,
1726                    error_report: None,
1727                    error_class: None,
1728                    llm_request: None,
1729                    llm_response: None,
1730                    tool_call: None,
1731                    tool_result: None,
1732                },
1733                None,
1734            )
1735            .await
1736            .unwrap();
1737
1738        assert!(matches!(report.decision, Some(HookDecision::Deny { .. })));
1739        assert_eq!(report.outcomes.len(), 1);
1740        assert_eq!(observed_runs.load(AtomicOrdering::SeqCst), 0);
1741        // A foreground deny short-circuits the loop, so only the hook that
1742        // actually ran is reported as started — the skipped lower-priority
1743        // observer must not appear (it never began execution).
1744        assert_eq!(report.started, vec![HookId::new("guardrail-deny")]);
1745    }
1746
1747    #[tokio::test]
1748    async fn deny_decision_uses_configured_hook_id_over_runtime_payload() {
1749        let configured_hook_id = HookId::new("configured-guardrail");
1750        let returned_hook_id = HookId::new("runtime-payload-identity");
1751
1752        let mut config = HooksConfig::default();
1753        config.entries = vec![HookEntryConfig {
1754            id: configured_hook_id.clone(),
1755            point: HookPoint::PreToolExecution,
1756            capability: HookCapability::Guardrail,
1757            runtime: runtime_in_process("guardrail"),
1758            ..Default::default()
1759        }];
1760
1761        let engine = DefaultHookEngine::new(config);
1762        engine
1763            .register_in_process_handler(
1764                "guardrail",
1765                static_handler(RuntimeHookResponse {
1766                    decision: Some(HookDecision::deny(
1767                        returned_hook_id,
1768                        HookReasonCode::PolicyViolation,
1769                        "deny",
1770                        None,
1771                    )),
1772                }),
1773            )
1774            .await
1775            .expect("in-process handler registration must succeed");
1776
1777        let report = engine
1778            .execute(
1779                HookInvocation {
1780                    point: HookPoint::PreToolExecution,
1781                    session_id: SessionId::new(),
1782                    turn_number: Some(1),
1783                    prompt_input: None,
1784                    error_report: None,
1785                    error_class: None,
1786                    llm_request: None,
1787                    llm_response: None,
1788                    tool_call: None,
1789                    tool_result: None,
1790                },
1791                None,
1792            )
1793            .await
1794            .unwrap();
1795
1796        match report.decision {
1797            Some(HookDecision::Deny { hook_id, .. }) => {
1798                assert_eq!(hook_id, configured_hook_id);
1799            }
1800            decision => panic!("expected deny decision, got {decision:?}"),
1801        }
1802
1803        assert_eq!(report.outcomes.len(), 1);
1804        assert_eq!(report.outcomes[0].hook_id, configured_hook_id);
1805        match &report.outcomes[0].decision {
1806            Some(HookDecision::Deny { hook_id, .. }) => {
1807                assert_eq!(hook_id, &configured_hook_id);
1808            }
1809            decision => panic!("expected outcome deny decision, got {decision:?}"),
1810        }
1811    }
1812
1813    #[tokio::test]
1814    async fn background_guardrail_is_rejected() {
1815        let mut config = HooksConfig::default();
1816        config.entries = vec![HookEntryConfig {
1817            id: HookId::new("run-start-bg-guardrail"),
1818            point: HookPoint::RunStarted,
1819            mode: HookExecutionMode::Background,
1820            capability: HookCapability::Guardrail,
1821            runtime: runtime_in_process("guardrail"),
1822            ..Default::default()
1823        }];
1824
1825        let engine = DefaultHookEngine::new(config);
1826        let err = engine
1827            .execute(
1828                HookInvocation {
1829                    point: HookPoint::RunStarted,
1830                    session_id: SessionId::new(),
1831                    turn_number: Some(0),
1832                    prompt_input: None,
1833                    error_report: None,
1834                    error_class: None,
1835                    llm_request: None,
1836                    llm_response: None,
1837                    tool_call: None,
1838                    tool_result: None,
1839                },
1840                None,
1841            )
1842            .await
1843            .expect_err("invalid background guardrail hook must be rejected");
1844
1845        assert!(matches!(err, HookEngineError::InvalidConfiguration(_)));
1846    }
1847
1848    #[tokio::test]
1849    async fn post_background_guardrail_is_rejected_before_deny_can_be_erased() {
1850        let mut config = HooksConfig::default();
1851        config.entries = vec![HookEntryConfig {
1852            id: HookId::new("post-bg-guardrail"),
1853            point: HookPoint::PostToolExecution,
1854            mode: HookExecutionMode::Background,
1855            capability: HookCapability::Guardrail,
1856            runtime: runtime_in_process("guardrail"),
1857            ..Default::default()
1858        }];
1859
1860        let engine = DefaultHookEngine::new(config);
1861        let err = engine
1862            .execute(
1863                HookInvocation {
1864                    point: HookPoint::PostToolExecution,
1865                    session_id: SessionId::new(),
1866                    turn_number: Some(1),
1867                    prompt_input: None,
1868                    error_report: None,
1869                    error_class: None,
1870                    llm_request: None,
1871                    llm_response: None,
1872                    tool_call: None,
1873                    tool_result: None,
1874                },
1875                None,
1876            )
1877            .await
1878            .expect_err("invalid post background guardrail hook must be rejected");
1879
1880        assert!(matches!(err, HookEngineError::InvalidConfiguration(_)));
1881    }
1882
1883    #[tokio::test]
1884    async fn command_runtime_hook_executes() {
1885        let mut config = HooksConfig::default();
1886        config.entries = vec![HookEntryConfig {
1887            id: HookId::new("command-hook"),
1888            point: HookPoint::PreToolExecution,
1889            runtime: HookAdapterConfig::from_kind_and_value(
1890                HookRuntimeKind::Command,
1891                Some(serde_json::json!({
1892                    "command": "sh",
1893                    "args": ["-c", "cat >/dev/null; printf '{}'"],
1894                    "env": {}
1895                })),
1896            )
1897            .unwrap_or_default(),
1898            ..Default::default()
1899        }];
1900
1901        let engine = DefaultHookEngine::new(config);
1902        let report = engine
1903            .execute(
1904                HookInvocation {
1905                    point: HookPoint::PreToolExecution,
1906                    session_id: SessionId::new(),
1907                    turn_number: Some(1),
1908                    prompt_input: None,
1909                    error_report: None,
1910                    error_class: None,
1911                    llm_request: None,
1912                    llm_response: None,
1913                    tool_call: None,
1914                    tool_result: None,
1915                },
1916                None,
1917            )
1918            .await
1919            .unwrap();
1920        assert_eq!(report.outcomes.len(), 1);
1921        assert!(
1922            report.outcomes[0].failure_reason.is_none(),
1923            "command runtime error: {:?}",
1924            report.outcomes[0].failure_reason
1925        );
1926    }
1927
1928    #[tokio::test]
1929    #[cfg(not(target_arch = "wasm32"))]
1930    async fn command_runtime_timeout_covers_stdin_write() {
1931        let mut config = HooksConfig {
1932            payload_max_bytes: 1024 * 1024,
1933            ..Default::default()
1934        };
1935        config.entries = vec![HookEntryConfig {
1936            id: HookId::new("command-hook-stdin-timeout"),
1937            point: HookPoint::RunStarted,
1938            timeout_ms: Some(25),
1939            runtime: HookAdapterConfig::from_kind_and_value(
1940                HookRuntimeKind::Command,
1941                Some(serde_json::json!({
1942                    "command": "sh",
1943                    "args": ["-c", "sleep 5"],
1944                    "env": {}
1945                })),
1946            )
1947            .unwrap_or_default(),
1948            ..Default::default()
1949        }];
1950
1951        let engine = DefaultHookEngine::new(config);
1952        let mut invocation = invocation(HookPoint::RunStarted, SessionId::new());
1953        invocation.prompt_input = Some(RunInput::from(ContentInput::Text("x".repeat(256 * 1024))));
1954
1955        let err = engine
1956            .execute(invocation, None)
1957            .await
1958            .expect_err("non-reading command must time out while stdin write is pending");
1959        assert!(
1960            matches!(err, HookEngineError::Timeout { .. }),
1961            "expected timeout, got {err:?}"
1962        );
1963    }
1964
1965    #[tokio::test]
1966    #[ignore = "integration-real: binds TCP port and makes real HTTP request"]
1967    async fn http_runtime_hook_executes() {
1968        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1969
1970        // Bind a mock HTTP server. The listener is ready immediately after bind
1971        // (no sleep race needed).
1972        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1973        let addr = listener.local_addr().unwrap();
1974        tokio::spawn(async move {
1975            // Accept connections in a loop so retries/keep-alive don't break.
1976            loop {
1977                let Ok((mut socket, _)) = listener.accept().await else {
1978                    break;
1979                };
1980                tokio::spawn(async move {
1981                    let mut buf = vec![0_u8; 4096];
1982                    let _ = socket.read(&mut buf).await;
1983                    let body = "{}";
1984                    let response = format!(
1985                        "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1986                        body.len(),
1987                        body
1988                    );
1989                    let _ = socket.write_all(response.as_bytes()).await;
1990                });
1991            }
1992        });
1993
1994        let mut config = HooksConfig::default();
1995        // Use an explicit generous timeout so this test doesn't flake under
1996        // parallel test load (the default 5 000 ms is borderline on CI).
1997        config.default_timeout_ms = 30_000;
1998        config.entries = vec![HookEntryConfig {
1999            id: HookId::new("http-hook"),
2000            point: HookPoint::PreToolExecution,
2001            runtime: HookAdapterConfig::from_kind_and_value(
2002                HookRuntimeKind::Http,
2003                Some(serde_json::json!({
2004                    "url": format!("http://{}/hook", addr),
2005                    "method": "POST",
2006                    "headers": {}
2007                })),
2008            )
2009            .unwrap_or_default(),
2010            ..Default::default()
2011        }];
2012
2013        let engine = DefaultHookEngine::new(config);
2014        let report = engine
2015            .execute(
2016                HookInvocation {
2017                    point: HookPoint::PreToolExecution,
2018                    session_id: SessionId::new(),
2019                    turn_number: Some(1),
2020                    prompt_input: None,
2021                    error_report: None,
2022                    error_class: None,
2023                    llm_request: None,
2024                    llm_response: None,
2025                    tool_call: None,
2026                    tool_result: None,
2027                },
2028                None,
2029            )
2030            .await
2031            .unwrap();
2032        assert_eq!(report.outcomes.len(), 1);
2033        assert!(
2034            report.outcomes[0].failure_reason.is_none(),
2035            "http runtime error: {:?}",
2036            report.outcomes[0].failure_reason
2037        );
2038    }
2039
2040    fn invocation(point: HookPoint, session_id: SessionId) -> HookInvocation {
2041        HookInvocation {
2042            point,
2043            session_id,
2044            turn_number: Some(1),
2045            prompt_input: None,
2046            error_report: None,
2047            error_class: None,
2048            llm_request: None,
2049            llm_response: None,
2050            tool_call: None,
2051            tool_result: None,
2052        }
2053    }
2054
2055    fn erroring_handler() -> InProcessHookHandler {
2056        Arc::new(move |_invocation| {
2057            Box::pin(async move { Result::<RuntimeHookResponse, String>::Err("boom".to_string()) })
2058        })
2059    }
2060
2061    // Gate for remediation row #289: re-registering an already-registered
2062    // in-process handler id must be an observable typed revision event, not a
2063    // silent `HashMap::insert` overwrite of executable behavior.
2064    #[tokio::test]
2065    async fn reregistering_in_process_handler_id_is_observable_revision() {
2066        let engine = DefaultHookEngine::new(HooksConfig::default());
2067
2068        let first = engine
2069            .register_in_process_handler(
2070                "shared-id",
2071                static_handler(RuntimeHookResponse { decision: None }),
2072            )
2073            .await
2074            .expect("first registration must succeed");
2075        assert!(
2076            matches!(first, HandlerRegistrationOutcome::Registered { .. }),
2077            "fresh id must register, got {first:?}"
2078        );
2079
2080        let second = engine
2081            .register_in_process_handler(
2082                "shared-id",
2083                static_handler(RuntimeHookResponse {
2084                    decision: Some(HookDecision::Allow),
2085                }),
2086            )
2087            .await
2088            .expect("re-registration must succeed");
2089
2090        match second {
2091            HandlerRegistrationOutcome::Revised { previous, revision } => {
2092                assert_eq!(
2093                    previous,
2094                    first.revision(),
2095                    "revision event must carry the prior revision"
2096                );
2097                assert_ne!(
2098                    revision,
2099                    first.revision(),
2100                    "re-registration must bump the handler revision (observable change)"
2101                );
2102            }
2103            other => panic!("re-registration must be a typed Revised event, got {other:?}"),
2104        }
2105        assert!(second.is_revision());
2106    }
2107
2108    // Gate for remediation row #280 (part 1): two hook entries sharing an id
2109    // are rejected at validation, so identity is registry-owned not
2110    // list-ordinal.
2111    #[tokio::test]
2112    async fn duplicate_hook_ids_are_rejected_at_validation() {
2113        let mut config = HooksConfig::default();
2114        config.entries = vec![
2115            HookEntryConfig {
2116                id: HookId::new("dup"),
2117                point: HookPoint::PreToolExecution,
2118                runtime: runtime_in_process("a"),
2119                capability: HookCapability::Observe,
2120                ..Default::default()
2121            },
2122            HookEntryConfig {
2123                id: HookId::new("dup"),
2124                point: HookPoint::PreToolExecution,
2125                runtime: runtime_in_process("b"),
2126                capability: HookCapability::Observe,
2127                ..Default::default()
2128            },
2129        ];
2130
2131        let engine = DefaultHookEngine::new(config);
2132        let err = engine
2133            .execute(
2134                invocation(HookPoint::PreToolExecution, SessionId::new()),
2135                None,
2136            )
2137            .await
2138            .expect_err("duplicate hook ids must be rejected at validation");
2139        assert!(matches!(err, HookEngineError::InvalidConfiguration(_)));
2140    }
2141
2142    // Gate for remediation row #280 (part 2): disabling a hook by id affects
2143    // exactly one canonical hook, and a hook is executed once per id.
2144    #[tokio::test]
2145    async fn disable_by_id_affects_one_canonical_hook_executed_once() {
2146        let mut config = HooksConfig::default();
2147        config.entries = vec![
2148            HookEntryConfig {
2149                id: HookId::new("keep"),
2150                point: HookPoint::PreToolExecution,
2151                runtime: runtime_in_process("keep"),
2152                capability: HookCapability::Observe,
2153                ..Default::default()
2154            },
2155            HookEntryConfig {
2156                id: HookId::new("drop"),
2157                point: HookPoint::PreToolExecution,
2158                runtime: runtime_in_process("drop"),
2159                capability: HookCapability::Observe,
2160                ..Default::default()
2161            },
2162        ];
2163
2164        let keep_runs = Arc::new(AtomicUsize::new(0));
2165        let keep_counter = keep_runs.clone();
2166
2167        let engine = DefaultHookEngine::new(config);
2168        engine
2169            .register_in_process_handler(
2170                "keep",
2171                Arc::new(move |_invocation| {
2172                    let keep_counter = keep_counter.clone();
2173                    Box::pin(async move {
2174                        keep_counter.fetch_add(1, AtomicOrdering::SeqCst);
2175                        Ok(RuntimeHookResponse {
2176                            decision: Some(HookDecision::Allow),
2177                        })
2178                    })
2179                }),
2180            )
2181            .await
2182            .expect("register keep handler");
2183        engine
2184            .register_in_process_handler(
2185                "drop",
2186                static_handler(RuntimeHookResponse {
2187                    decision: Some(HookDecision::Allow),
2188                }),
2189            )
2190            .await
2191            .expect("register drop handler");
2192
2193        let overrides = HookRunOverrides {
2194            entries: Vec::new(),
2195            disable: vec![HookId::new("drop")],
2196        };
2197        let report = engine
2198            .execute(
2199                invocation(HookPoint::PreToolExecution, SessionId::new()),
2200                Some(&overrides),
2201            )
2202            .await
2203            .expect("execute with disable override");
2204
2205        let ids: Vec<_> = report
2206            .outcomes
2207            .iter()
2208            .map(|outcome| outcome.hook_id.clone())
2209            .collect();
2210        assert_eq!(
2211            ids,
2212            vec![HookId::new("keep")],
2213            "disabling 'drop' must leave exactly the one canonical 'keep' hook"
2214        );
2215        assert_eq!(
2216            keep_runs.load(AtomicOrdering::SeqCst),
2217            1,
2218            "the surviving canonical hook must execute exactly once"
2219        );
2220    }
2221
2222    // Gate for remediation row #231 (part 1): a malformed command adapter
2223    // config is rejected at the config-deserialization boundary — the single
2224    // typed owner `HookAdapterConfig` is parsed once at config load, so a
2225    // missing required field fails closed there rather than being deferred to a
2226    // per-execution JSON re-parse.
2227    #[test]
2228    fn malformed_command_adapter_config_rejected_at_config_load() {
2229        // Missing the required `command` field for the command adapter.
2230        let err = serde_json::from_value::<HookAdapterConfig>(serde_json::json!({
2231            "type": "command",
2232            "args": ["x"]
2233        }))
2234        .expect_err("malformed command adapter must fail closed at deserialize");
2235        assert!(
2236            err.to_string().contains("command"),
2237            "unexpected error: {err}"
2238        );
2239
2240        // The same failure surfaces when the whole entry is deserialized from a
2241        // config document (the real config-load path).
2242        let entry_err = serde_json::from_value::<HookEntryConfig>(serde_json::json!({
2243            "id": "bad-command",
2244            "point": "pre_tool_execution",
2245            "runtime": { "type": "command", "args": ["x"] }
2246        }))
2247        .expect_err("malformed command adapter entry must fail closed at deserialize");
2248        assert!(
2249            entry_err.to_string().contains("command"),
2250            "unexpected error: {entry_err}"
2251        );
2252    }
2253
2254    // Gate for remediation row #231 (part 2): a malformed HTTP adapter config is
2255    // likewise rejected at the config-deserialization boundary.
2256    #[test]
2257    fn malformed_http_adapter_config_rejected_at_config_load() {
2258        // Missing the required `url` field for the HTTP adapter.
2259        let err = serde_json::from_value::<HookAdapterConfig>(serde_json::json!({
2260            "type": "http",
2261            "method": "POST"
2262        }))
2263        .expect_err("malformed http adapter must fail closed at deserialize");
2264        assert!(err.to_string().contains("url"), "unexpected error: {err}");
2265    }
2266
2267    // Gate for remediation row #35 (part 1): a full background queue produces a
2268    // typed Skipped signal against the observable ledger, not a silent
2269    // `continue`.
2270    #[tokio::test]
2271    async fn background_queue_full_records_typed_skip_signal() {
2272        let mut config = HooksConfig::default();
2273        // Single background slot so the second background hook cannot be
2274        // dispatched while the first holds the only permit.
2275        config.background_max_concurrency = 1;
2276        config.entries = vec![
2277            HookEntryConfig {
2278                id: HookId::new("bg-first"),
2279                point: HookPoint::PostToolExecution,
2280                mode: HookExecutionMode::Background,
2281                capability: HookCapability::Observe,
2282                runtime: runtime_in_process("bg-slow"),
2283                ..Default::default()
2284            },
2285            HookEntryConfig {
2286                id: HookId::new("bg-second"),
2287                point: HookPoint::PostToolExecution,
2288                mode: HookExecutionMode::Background,
2289                capability: HookCapability::Observe,
2290                runtime: runtime_in_process("bg-slow"),
2291                ..Default::default()
2292            },
2293        ];
2294
2295        let engine = DefaultHookEngine::new(config);
2296        engine
2297            .register_in_process_handler(
2298                "bg-slow",
2299                delayed_handler(500, RuntimeHookResponse { decision: None }),
2300            )
2301            .await
2302            .expect("register bg-slow handler");
2303
2304        let report = engine
2305            .execute(
2306                invocation(HookPoint::PostToolExecution, SessionId::new()),
2307                None,
2308            )
2309            .await
2310            .expect("execute background hooks");
2311
2312        // Only the hook that acquired the permit and was spawned actually began
2313        // executing — the saturated/skipped second hook must not be reported as
2314        // started (and so earns no HookStarted event).
2315        assert_eq!(report.started, vec![HookId::new("bg-first")]);
2316
2317        // The first hook took the only permit at dispatch time; the second was
2318        // queue-full and recorded a typed Skipped signal (not a silent skip).
2319        let signals = engine.background_dispatch_ledger().snapshot().await;
2320        assert_eq!(
2321            signals,
2322            vec![BackgroundDispatchSignal::Skipped {
2323                hook_id: HookId::new("bg-second"),
2324            }],
2325            "queue-full must record a typed Skipped signal, got {signals:?}"
2326        );
2327    }
2328
2329    // Gate for remediation row #35 (part 2): a failed background hook records a
2330    // typed Dropped signal against the observable ledger instead of a
2331    // warn-only silent loss.
2332    #[tokio::test]
2333    async fn background_failure_records_typed_dropped_signal() {
2334        let mut config = HooksConfig::default();
2335        config.entries = vec![HookEntryConfig {
2336            id: HookId::new("bg-failing"),
2337            point: HookPoint::PostToolExecution,
2338            mode: HookExecutionMode::Background,
2339            capability: HookCapability::Observe,
2340            runtime: runtime_in_process("bg-failing"),
2341            ..Default::default()
2342        }];
2343
2344        let engine = DefaultHookEngine::new(config);
2345        engine
2346            .register_in_process_handler("bg-failing", erroring_handler())
2347            .await
2348            .expect("register failing handler");
2349
2350        engine
2351            .execute(
2352                invocation(HookPoint::PostToolExecution, SessionId::new()),
2353                None,
2354            )
2355            .await
2356            .expect("execute failing background hook");
2357
2358        // Drain the ledger until the background task has run and recorded its
2359        // typed Dropped signal (the task is reaped/lifecycle-bound, so this
2360        // converges quickly).
2361        let mut signals = Vec::new();
2362        for _ in 0..200 {
2363            signals = engine.background_dispatch_ledger().snapshot().await;
2364            if !signals.is_empty() {
2365                break;
2366            }
2367            tokio::time::sleep(Duration::from_millis(5)).await;
2368        }
2369        assert_eq!(
2370            signals.len(),
2371            1,
2372            "expected one dropped signal, got {signals:?}"
2373        );
2374        match &signals[0] {
2375            BackgroundDispatchSignal::Dropped { hook_id, reason } => {
2376                assert_eq!(hook_id, &HookId::new("bg-failing"));
2377                assert!(
2378                    reason.contains("bg-failing"),
2379                    "dropped reason must carry the typed engine failure, got {reason}"
2380                );
2381            }
2382            other => panic!("expected a typed Dropped signal, got {other:?}"),
2383        }
2384    }
2385}