1#[cfg(target_arch = "wasm32")]
5mod tokio {
6 pub use tokio_with_wasm::alias::*;
7}
8
9inventory::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
22inventory::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#[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#[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 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 fn timeout_ms_for(&self, entry: &HookEntryConfig) -> u64 {
138 entry.timeout_ms.unwrap_or(self.default_timeout_ms)
139 }
140
141 pub fn payload_max_bytes(&self) -> usize {
143 self.payload_max_bytes
144 }
145
146 pub fn background_max_concurrency(&self) -> usize {
148 self.background_max_concurrency
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum BackgroundDispatchSignal {
161 Skipped { hook_id: HookId },
163 Dropped { hook_id: HookId, reason: String },
166}
167
168#[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 pub async fn snapshot(&self) -> Vec<BackgroundDispatchSignal> {
189 self.inner.lock().await.clone()
190 }
191
192 pub async fn drain(&self) -> Vec<BackgroundDispatchSignal> {
194 std::mem::take(&mut *self.inner.lock().await)
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum HandlerRegistrationOutcome {
208 Registered { revision: HookHandlerRevision },
210 Revised {
213 previous: HookHandlerRevision,
214 revision: HookHandlerRevision,
215 },
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
223pub struct HookHandlerRevision(pub u64);
224
225impl HandlerRegistrationOutcome {
226 pub fn revision(&self) -> HookHandlerRevision {
228 match self {
229 Self::Registered { revision } | Self::Revised { revision, .. } => *revision,
230 }
231 }
232
233 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
246struct RegisteredHandler {
252 handler: InProcessHookHandler,
253 revision: HookHandlerRevision,
254}
255
256enum 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#[derive(Clone)]
301pub struct DefaultHookEngine {
302 base_entries: Arc<Vec<HookEntryConfig>>,
303 base_adapters: Arc<HashMap<HookId, HookAdapterConfig>>,
307 base_validation_error: Option<String>,
308 policy: HookExecutionPolicy,
311 http_client: Arc<OnceLock<reqwest::Client>>,
312 in_process_handlers: Arc<std::sync::RwLock<HashMap<InProcessHookHandlerId, RegisteredHandler>>>,
313 background_dispatch_ledger: BackgroundDispatchLedger,
317 background_slots: Arc<Semaphore>,
318 #[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 let (base_adapters, base_validation_error) = match Self::resolve_base(&config.entries) {
341 Ok(()) => (Self::resolve_adapters(&config.entries), None),
345 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 pub fn background_dispatch_ledger(&self) -> &BackgroundDispatchLedger {
369 &self.background_dispatch_ledger
370 }
371
372 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 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[test]
2228 fn malformed_command_adapter_config_rejected_at_config_load() {
2229 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 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 #[test]
2257 fn malformed_http_adapter_config_rejected_at_config_load() {
2258 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 #[tokio::test]
2271 async fn background_queue_full_records_typed_skip_signal() {
2272 let mut config = HooksConfig::default();
2273 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 assert_eq!(report.started, vec![HookId::new("bg-first")]);
2316
2317 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 #[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 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}