From e87d7bd0663627a0c0e2bb678ee9b7947884f86b Mon Sep 17 00:00:00 2001 From: "firat.sertgoz" Date: Fri, 20 Feb 2026 03:00:54 +0400 Subject: [PATCH] feat: extend lifecycle hooks with declarative bundles (#176) * feat: add bundled and declarative hook bundle loading * fix: load plugin hooks only for active extensions * fix: avoid duplicate plugin hook registration * security: harden outbound webhook hooks * fix: pin webhook DNS resolutions for outbound hooks * fix: block IPv4-mapped local webhook targets * style: format webhook hardening changes for CI * fix: pass HookRegistry to ExtensionManager in AppBuilder After merging main (which extracted AppBuilder from main.rs in #198), the ExtensionManager::new() call in app.rs was missing the `hooks` parameter that PR #176 added. This moves HookRegistry creation before init_extensions() and threads it through, matching the existing pattern in main.rs. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Illia Polosukhin Co-authored-by: Claude Opus 4.6 --- FEATURE_PARITY.md | 12 +- src/app.rs | 9 +- src/extensions/manager.rs | 62 ++ src/hooks/bootstrap.rs | 378 ++++++++ src/hooks/bundled.rs | 1234 ++++++++++++++++++++++++++ src/hooks/hook.rs | 23 +- src/hooks/mod.rs | 6 + src/hooks/registry.rs | 55 +- src/main.rs | 38 +- src/tools/builtin/extension_tools.rs | 1 + 10 files changed, 1802 insertions(+), 16 deletions(-) create mode 100644 src/hooks/bootstrap.rs create mode 100644 src/hooks/bundled.rs diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 12020f10..d6e8fceb 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -278,7 +278,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Auth plugins | ✅ | ❌ | | | Memory plugins | ✅ | ❌ | Custom backends | | Tool plugins | ✅ | ✅ | WASM tools | -| Hook plugins | ✅ | ❌ | | +| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities | | Provider plugins | ✅ | ❌ | | | Plugin CLI (`install`, `list`) | ✅ | ✅ | `tool` subcommand | | ClawHub registry | ✅ | ❌ | Discovery | @@ -421,10 +421,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `transcribeAudio` hook | ✅ | ❌ | P3 | | | `transformResponse` hook | ✅ | ✅ | P2 | | | `llm_input`/`llm_output` hooks | ✅ | ❌ | P3 | LLM payload inspection | -| Bundled hooks | ✅ | ❌ | P2 | | -| Plugin hooks | ✅ | ❌ | P3 | | -| Workspace hooks | ✅ | ❌ | P2 | Inline code | -| Outbound webhooks | ✅ | ❌ | P2 | | +| Bundled hooks | ✅ | ✅ | P2 | Audit + declarative rule/webhook hooks | +| Plugin hooks | ✅ | ✅ | P3 | Registered from WASM `capabilities.json` | +| Workspace hooks | ✅ | ✅ | P2 | `hooks/hooks.json` and `hooks/*.hook.json` | +| Outbound webhooks | ✅ | ✅ | P2 | Fire-and-forget lifecycle event delivery | | Heartbeat system | ✅ | ✅ | - | Periodic execution | | Gmail pub/sub | ✅ | ❌ | P3 | | @@ -528,7 +528,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ✅ Telegram channel (WASM, DM pairing, caption, /start) - ❌ WhatsApp channel - ✅ Multi-provider failover (`FailoverProvider` with retryable error classification) -- ✅ Hooks system (beforeInbound, beforeToolCall, beforeOutbound, onSessionStart, onSessionEnd, transformResponse) +- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks) ### P2 - Medium Priority - ❌ Media handling (images, PDFs) diff --git a/src/app.rs b/src/app.rs index 0209fb75..4a7e60d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -473,6 +473,7 @@ impl AppBuilder { pub async fn init_extensions( &self, tools: &Arc, + hooks: &Arc, ) -> Result< ( Arc, @@ -661,6 +662,7 @@ impl AppBuilder { Arc::clone(&mcp_session_manager), Arc::clone(secrets), Arc::clone(tools), + Some(Arc::clone(hooks)), wasm_tool_runtime.clone(), self.config.wasm.tools_dir.clone(), self.config.channels.wasm_channels_dir.clone(), @@ -697,8 +699,12 @@ impl AppBuilder { let (llm, cheap_llm) = self.init_llm()?; let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?; + + // Create hook registry early so runtime extension activation can register hooks. + let hooks = Arc::new(HookRegistry::new()); + let (mcp_session_manager, wasm_tool_runtime, extension_manager) = - self.init_extensions(&tools).await?; + self.init_extensions(&tools, &hooks).await?; // Seed workspace and backfill embeddings if let Some(ref ws) = workspace { @@ -741,7 +747,6 @@ impl AppBuilder { }; let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs)); - let hooks = Arc::new(HookRegistry::new()); let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new( crate::agent::cost_guard::CostGuardConfig { max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents, diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index e6e7d264..daca5a33 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -16,6 +16,7 @@ use crate::extensions::{ ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult, InstalledExtension, RegistryEntry, ResultSource, SearchResult, }; +use crate::hooks::HookRegistry; use crate::secrets::{CreateSecretParams, SecretsStore}; use crate::tools::ToolRegistry; use crate::tools::mcp::McpClient; @@ -52,6 +53,7 @@ pub struct ExtensionManager { // Shared secrets: Arc, tool_registry: Arc, + hooks: Option>, pending_auth: RwLock>, /// Tunnel URL for remote OAuth callbacks (used in future iterations). _tunnel_url: Option, @@ -66,6 +68,7 @@ impl ExtensionManager { mcp_session_manager: Arc, secrets: Arc, tool_registry: Arc, + hooks: Option>, wasm_tool_runtime: Option>, wasm_tools_dir: PathBuf, wasm_channels_dir: PathBuf, @@ -83,6 +86,7 @@ impl ExtensionManager { wasm_channels_dir, secrets, tool_registry, + hooks, pending_auth: RwLock::new(HashMap::new()), _tunnel_url: tunnel_url, user_id, @@ -320,6 +324,21 @@ impl ExtensionManager { // Unregister from tool registry self.tool_registry.unregister(name).await; + // Unregister hooks registered from this plugin source. + let removed_hooks = self + .unregister_hook_prefix(&format!("plugin.tool:{}::", name)) + .await + + self + .unregister_hook_prefix(&format!("plugin.dev_tool:{}::", name)) + .await; + if removed_hooks > 0 { + tracing::info!( + extension = name, + removed_hooks = removed_hooks, + "Removed plugin hooks for WASM tool" + ); + } + // Delete files let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name)); let cap_path = self @@ -969,6 +988,34 @@ impl ExtensionManager { .await .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + if let Some(ref hooks) = self.hooks + && let Some(cap_path) = cap_path_option + { + let source = format!("plugin.tool:{}", name); + let registration = + crate::hooks::bootstrap::register_plugin_bundle_from_capabilities_file( + hooks, &source, cap_path, + ) + .await; + + if registration.total_registered() > 0 { + tracing::info!( + extension = name, + hooks = registration.hooks, + outbound_webhooks = registration.outbound_webhooks, + "Registered plugin hooks for activated WASM tool" + ); + } + + if registration.errors > 0 { + tracing::warn!( + extension = name, + errors = registration.errors, + "Some plugin hooks failed to register" + ); + } + } + tracing::info!("Activated WASM tool '{}'", name); Ok(ActivateResult { @@ -1008,6 +1055,21 @@ impl ExtensionManager { let mut pending = self.pending_auth.write().await; pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300)); } + + async fn unregister_hook_prefix(&self, prefix: &str) -> usize { + let Some(ref hooks) = self.hooks else { + return 0; + }; + + let names = hooks.list().await; + let mut removed = 0; + for hook_name in names { + if hook_name.starts_with(prefix) && hooks.unregister(&hook_name).await { + removed += 1; + } + } + removed + } } /// Infer the extension kind from a URL. diff --git a/src/hooks/bootstrap.rs b/src/hooks/bootstrap.rs new file mode 100644 index 00000000..7e8a06a0 --- /dev/null +++ b/src/hooks/bootstrap.rs @@ -0,0 +1,378 @@ +//! Hook bootstrap helpers for loading bundled, plugin, and workspace hooks. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::channels::wasm::discover_channels; +use crate::hooks::bundled::{ + HookBundleConfig, HookRegistrationSummary, register_bundle, register_bundled_hooks, +}; +use crate::hooks::registry::HookRegistry; +use crate::tools::wasm::{discover_dev_tools, discover_tools}; +use crate::workspace::Workspace; + +/// Summary of hook bootstrap work done at startup. +#[derive(Debug, Default, Clone, Copy)] +pub struct HookBootstrapSummary { + /// Number of bundled built-in hooks registered. + pub bundled_hooks: usize, + /// Number of plugin-provided rule hooks registered. + pub plugin_hooks: usize, + /// Number of workspace-provided rule hooks registered. + pub workspace_hooks: usize, + /// Number of outbound webhook hooks registered. + pub outbound_webhooks: usize, + /// Number of invalid hook configs skipped. + pub errors: usize, +} + +impl HookBootstrapSummary { + /// Total number of hooks registered across all categories. + pub fn total_hooks(&self) -> usize { + self.bundled_hooks + self.plugin_hooks + self.workspace_hooks + self.outbound_webhooks + } +} + +/// Register bundled hooks, then load plugin and workspace hook bundles. +pub async fn bootstrap_hooks( + registry: &Arc, + workspace: Option<&Arc>, + wasm_tools_dir: &Path, + wasm_channels_dir: &Path, + active_tool_names: &[String], + active_channel_names: &[String], + dev_loaded_tool_names: &[String], +) -> HookBootstrapSummary { + let mut summary = HookBootstrapSummary::default(); + + let bundled = register_bundled_hooks(registry).await; + summary.bundled_hooks += bundled.hooks; + summary.outbound_webhooks += bundled.outbound_webhooks; + summary.errors += bundled.errors; + + let plugin = register_plugin_bundles( + registry, + wasm_tools_dir, + wasm_channels_dir, + active_tool_names, + active_channel_names, + dev_loaded_tool_names, + ) + .await; + summary.plugin_hooks += plugin.hooks; + summary.outbound_webhooks += plugin.outbound_webhooks; + summary.errors += plugin.errors; + + if let Some(workspace) = workspace { + let workspace_loaded = register_workspace_bundles(registry, workspace).await; + summary.workspace_hooks += workspace_loaded.hooks; + summary.outbound_webhooks += workspace_loaded.outbound_webhooks; + summary.errors += workspace_loaded.errors; + } + + summary +} + +async fn register_plugin_bundles( + registry: &Arc, + wasm_tools_dir: &Path, + wasm_channels_dir: &Path, + active_tool_names: &[String], + active_channel_names: &[String], + dev_loaded_tool_names: &[String], +) -> HookRegistrationSummary { + let mut summary = HookRegistrationSummary::default(); + let files = collect_plugin_capability_files( + wasm_tools_dir, + wasm_channels_dir, + active_tool_names, + active_channel_names, + dev_loaded_tool_names, + ) + .await; + + for (source, path) in files { + let registered = + register_plugin_bundle_from_capabilities_file(registry, &source, &path).await; + summary.merge(registered); + } + + summary +} + +/// Register a plugin hook bundle from a single capabilities file. +/// +/// This is used by startup bootstrap and by runtime extension activation. +pub async fn register_plugin_bundle_from_capabilities_file( + registry: &Arc, + source: &str, + path: &Path, +) -> HookRegistrationSummary { + match load_plugin_bundle_from_capabilities_file(path).await { + Ok(Some(bundle)) => register_bundle(registry, source, bundle).await, + Ok(None) => HookRegistrationSummary::default(), + Err(err) => { + tracing::warn!( + source = source, + path = %path.display(), + error = %err, + "Skipping plugin hook bundle" + ); + HookRegistrationSummary { + hooks: 0, + outbound_webhooks: 0, + errors: 1, + } + } + } +} + +async fn collect_plugin_capability_files( + wasm_tools_dir: &Path, + wasm_channels_dir: &Path, + active_tool_names: &[String], + active_channel_names: &[String], + dev_loaded_tool_names: &[String], +) -> Vec<(String, PathBuf)> { + let mut files: Vec<(String, PathBuf)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let active_tools: HashSet<&str> = active_tool_names.iter().map(String::as_str).collect(); + let active_channels: HashSet<&str> = active_channel_names.iter().map(String::as_str).collect(); + let dev_loaded_tools: HashSet<&str> = + dev_loaded_tool_names.iter().map(String::as_str).collect(); + + if wasm_tools_dir.exists() { + match discover_tools(wasm_tools_dir).await { + Ok(tools) => { + for (name, tool) in tools { + if let Some(path) = tool.capabilities_path + && active_tools.contains(name.as_str()) + && !dev_loaded_tools.contains(name.as_str()) + { + insert_unique(&mut files, &mut seen, format!("plugin.tool:{}", name), path); + } + } + } + Err(err) => { + tracing::warn!( + path = %wasm_tools_dir.display(), + error = %err, + "Failed to discover WASM tool capabilities for plugin hooks" + ); + } + } + } + + match discover_dev_tools().await { + Ok(dev_tools) => { + for (name, tool) in dev_tools { + if let Some(path) = tool.capabilities_path + && active_tools.contains(name.as_str()) + && dev_loaded_tools.contains(name.as_str()) + { + insert_unique( + &mut files, + &mut seen, + format!("plugin.dev_tool:{}", name), + path, + ); + } + } + } + Err(err) => { + tracing::debug!(error = %err, "No dev tool capabilities discovered for plugin hooks"); + } + } + + if wasm_channels_dir.exists() { + match discover_channels(wasm_channels_dir).await { + Ok(channels) => { + for (name, channel) in channels { + if let Some(path) = channel.capabilities_path + && active_channels.contains(name.as_str()) + { + insert_unique( + &mut files, + &mut seen, + format!("plugin.channel:{}", name), + path, + ); + } + } + } + Err(err) => { + tracing::warn!( + path = %wasm_channels_dir.display(), + error = %err, + "Failed to discover WASM channel capabilities for plugin hooks" + ); + } + } + } + + files.sort_by(|a, b| a.0.cmp(&b.0)); + files +} + +fn insert_unique( + files: &mut Vec<(String, PathBuf)>, + seen: &mut HashSet, + source: String, + path: PathBuf, +) { + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + files.push((source, path)); + } +} + +async fn load_plugin_bundle_from_capabilities_file( + path: &Path, +) -> Result, String> { + let bytes = tokio::fs::read(path) + .await + .map_err(|e| format!("read failed: {e}"))?; + + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {e}"))?; + + let Some(hooks_value) = extract_hooks_section(&value) else { + return Ok(None); + }; + + HookBundleConfig::from_value(hooks_value) + .map(Some) + .map_err(|e| e.to_string()) +} + +fn extract_hooks_section(root: &serde_json::Value) -> Option<&serde_json::Value> { + root.get("hooks") + .or_else(|| root.get("capabilities").and_then(|c| c.get("hooks"))) +} + +async fn register_workspace_bundles( + registry: &Arc, + workspace: &Arc, +) -> HookRegistrationSummary { + let mut summary = HookRegistrationSummary::default(); + + let paths = match workspace.list_all().await { + Ok(paths) => paths, + Err(err) => { + summary.errors += 1; + tracing::warn!(error = %err, "Failed to list workspace paths for hooks"); + return summary; + } + }; + + let mut hook_paths: Vec = paths + .into_iter() + .filter(|path| is_workspace_hook_file(path)) + .collect(); + hook_paths.sort(); + + for path in hook_paths { + let doc = match workspace.read(&path).await { + Ok(doc) => doc, + Err(err) => { + summary.errors += 1; + tracing::warn!(path = %path, error = %err, "Skipping unreadable workspace hook file"); + continue; + } + }; + + let parsed: serde_json::Value = match serde_json::from_str(&doc.content) { + Ok(value) => value, + Err(err) => { + summary.errors += 1; + tracing::warn!(path = %path, error = %err, "Workspace hook file is not valid JSON"); + continue; + } + }; + + let bundle = match parse_workspace_bundle(&parsed) { + Ok(bundle) => bundle, + Err(err) => { + summary.errors += 1; + tracing::warn!(path = %path, error = %err, "Skipping invalid workspace hook bundle"); + continue; + } + }; + + let source = format!("workspace:{}", path); + let registered = register_bundle(registry, &source, bundle).await; + summary.merge(registered); + } + + summary +} + +fn parse_workspace_bundle(value: &serde_json::Value) -> Result { + if let Some(nested) = value.get("hooks") { + HookBundleConfig::from_value(nested).map_err(|e| e.to_string()) + } else { + HookBundleConfig::from_value(value).map_err(|e| e.to_string()) + } +} + +fn is_workspace_hook_file(path: &str) -> bool { + path == "hooks/hooks.json" || (path.starts_with("hooks/") && path.ends_with(".hook.json")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_hooks_section_from_tool_caps() { + let value = serde_json::json!({ + "http": {"allowlist": []}, + "hooks": {"rules": []} + }); + + let extracted = extract_hooks_section(&value).unwrap(); + assert!(extracted.get("rules").is_some()); + } + + #[test] + fn test_extract_hooks_section_from_channel_caps() { + let value = serde_json::json!({ + "type": "channel", + "capabilities": { + "hooks": { + "rules": [] + } + } + }); + + let extracted = extract_hooks_section(&value).unwrap(); + assert!(extracted.get("rules").is_some()); + } + + #[test] + fn test_workspace_hook_file_filter() { + assert!(is_workspace_hook_file("hooks/hooks.json")); + assert!(is_workspace_hook_file("hooks/redact.hook.json")); + assert!(!is_workspace_hook_file("hooks/readme.md")); + assert!(!is_workspace_hook_file("MEMORY.md")); + } + + #[test] + fn test_parse_workspace_bundle_wrapped_hooks() { + let value = serde_json::json!({ + "hooks": { + "rules": [ + { + "name": "append-bang", + "points": ["beforeInbound"], + "append": "!" + } + ] + } + }); + + let bundle = parse_workspace_bundle(&value).unwrap(); + assert_eq!(bundle.rules.len(), 1); + } +} diff --git a/src/hooks/bundled.rs b/src/hooks/bundled.rs new file mode 100644 index 00000000..9ca1fe92 --- /dev/null +++ b/src/hooks/bundled.rs @@ -0,0 +1,1234 @@ +//! Bundled hook implementations and declarative hook registration. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use regex::Regex; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Semaphore; + +use crate::hooks::{ + Hook, HookContext, HookError, HookEvent, HookFailureMode, HookOutcome, HookPoint, HookRegistry, +}; + +const DEFAULT_RULE_PRIORITY: u32 = 100; +const DEFAULT_WEBHOOK_PRIORITY: u32 = 300; +const DEFAULT_WEBHOOK_TIMEOUT_MS: u64 = 2000; +const DEFAULT_WEBHOOK_MAX_IN_FLIGHT: usize = 32; +const MAX_HOOK_TIMEOUT_MS: u64 = 30_000; + +const ALL_HOOK_POINTS: [HookPoint; 6] = [ + HookPoint::BeforeInbound, + HookPoint::BeforeToolCall, + HookPoint::BeforeOutbound, + HookPoint::OnSessionStart, + HookPoint::OnSessionEnd, + HookPoint::TransformResponse, +]; + +/// Errors while parsing or compiling declarative hook bundles. +#[derive(Debug, thiserror::Error)] +pub enum HookBundleError { + #[error("Invalid hook bundle format: {0}")] + InvalidFormat(String), + + #[error("Hook '{hook}' must declare at least one hook point")] + MissingHookPoints { hook: String }, + + #[error("Hook '{hook}' has invalid regex '{pattern}': {reason}")] + InvalidRegex { + hook: String, + pattern: String, + reason: String, + }, + + #[error("Hook '{hook}' timeout must be between 1 and {max_ms} ms")] + InvalidTimeout { hook: String, max_ms: u64 }, + + #[error("Outbound webhook hook '{hook}' has invalid url: {url}")] + InvalidWebhookUrl { hook: String, url: String }, + + #[error("Outbound webhook hook '{hook}' must use https, got '{scheme}'")] + InvalidWebhookScheme { hook: String, scheme: String }, + + #[error("Outbound webhook hook '{hook}' cannot target host '{host}'")] + ForbiddenWebhookHost { hook: String, host: String }, + + #[error("Outbound webhook hook '{hook}' has invalid header '{header}': {reason}")] + InvalidWebhookHeader { + hook: String, + header: String, + reason: String, + }, + + #[error("Outbound webhook hook '{hook}' cannot set restricted header '{header}'")] + ForbiddenWebhookHeader { hook: String, header: String }, + + #[error("Outbound webhook hook '{hook}' max_in_flight must be at least 1")] + InvalidWebhookMaxInFlight { hook: String }, +} + +/// A declarative hook bundle loaded from workspace files or extension capabilities. +/// +/// Supports two bundled hook types: +/// - Rule hooks (`rules`) for reject/regex transform/prepend/append logic +/// - Outbound webhook hooks (`outbound_webhooks`) for fire-and-forget event delivery +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HookBundleConfig { + /// Declarative content/tool/session rules. + #[serde(default)] + pub rules: Vec, + /// Fire-and-forget webhook notifications on selected hook points. + #[serde(default)] + pub outbound_webhooks: Vec, +} + +impl HookBundleConfig { + /// Parse a hook bundle from JSON value. + /// + /// Accepts either: + /// - object form: `{ "rules": [...], "outbound_webhooks": [...] }` + /// - array form: `[ {rule}, {rule} ]` (shorthand for rules only) + pub fn from_value(value: &serde_json::Value) -> Result { + if value.is_array() { + let rules: Vec = serde_json::from_value(value.clone()) + .map_err(|e| HookBundleError::InvalidFormat(e.to_string()))?; + return Ok(Self { + rules, + outbound_webhooks: Vec::new(), + }); + } + + serde_json::from_value(value.clone()) + .map_err(|e| HookBundleError::InvalidFormat(e.to_string())) + } +} + +/// Summary of hook registrations performed from a bundle. +#[derive(Debug, Default, Clone, Copy)] +pub struct HookRegistrationSummary { + /// Number of non-webhook hook registrations (audit/rule hooks). + pub hooks: usize, + /// Number of outbound webhook hook registrations. + pub outbound_webhooks: usize, + /// Number of invalid/failed registrations skipped. + pub errors: usize, +} + +impl HookRegistrationSummary { + /// Total number of hooks successfully registered. + pub fn total_registered(&self) -> usize { + self.hooks + self.outbound_webhooks + } + + pub fn merge(&mut self, other: HookRegistrationSummary) { + self.hooks += other.hooks; + self.outbound_webhooks += other.outbound_webhooks; + self.errors += other.errors; + } +} + +/// Register bundled built-in hooks that ship with IronClaw. +pub async fn register_bundled_hooks(registry: &Arc) -> HookRegistrationSummary { + registry + .register_with_priority(Arc::new(AuditLogHook), 25) + .await; + + HookRegistrationSummary { + hooks: 1, + outbound_webhooks: 0, + errors: 0, + } +} + +/// Register all hooks from a declarative bundle. +pub async fn register_bundle( + registry: &Arc, + source: &str, + bundle: HookBundleConfig, +) -> HookRegistrationSummary { + let mut summary = HookRegistrationSummary::default(); + + for rule in bundle.rules { + match RuleHook::from_config(source, rule) { + Ok((hook, priority)) => { + registry + .register_with_priority(Arc::new(hook), priority) + .await; + summary.hooks += 1; + } + Err(err) => { + summary.errors += 1; + tracing::warn!(source = source, error = %err, "Skipping invalid declarative hook rule"); + } + } + } + + for webhook in bundle.outbound_webhooks { + match OutboundWebhookHook::from_config(source, webhook) { + Ok((hook, priority)) => { + registry + .register_with_priority(Arc::new(hook), priority) + .await; + summary.outbound_webhooks += 1; + } + Err(err) => { + summary.errors += 1; + tracing::warn!(source = source, error = %err, "Skipping invalid outbound webhook hook"); + } + } + } + + summary +} + +/// Declarative regex/string rule hook. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookRuleConfig { + /// Stable hook name (scoped with source during registration). + pub name: String, + /// Lifecycle points where this rule applies. + pub points: Vec, + /// Optional priority override (lower runs first). + #[serde(default)] + pub priority: Option, + /// Failure handling mode (default fail_open). + #[serde(default)] + pub failure_mode: Option, + /// Optional timeout override for this hook in milliseconds. + #[serde(default)] + pub timeout_ms: Option, + /// Optional regex guard. If provided and no match, rule is a no-op. + #[serde(default)] + pub when_regex: Option, + /// Optional immediate reject reason if guard matches. + #[serde(default)] + pub reject_reason: Option, + /// Regex replacements applied in order. + #[serde(default)] + pub replacements: Vec, + /// Text prepended to the event's primary content. + #[serde(default)] + pub prepend: Option, + /// Text appended to the event's primary content. + #[serde(default)] + pub append: Option, +} + +/// A single regex replacement step in a rule hook. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegexReplacementConfig { + pub pattern: String, + pub replacement: String, +} + +/// Declarative fire-and-forget outbound webhook hook. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutboundWebhookConfig { + /// Stable webhook hook name (scoped with source during registration). + pub name: String, + /// Lifecycle points that trigger this webhook. + pub points: Vec, + /// Target URL. + pub url: String, + /// Optional static headers. + #[serde(default)] + pub headers: HashMap, + /// Optional timeout override in milliseconds. + #[serde(default)] + pub timeout_ms: Option, + /// Optional priority override (lower runs first). + #[serde(default)] + pub priority: Option, + /// Optional max number of concurrent in-flight deliveries. + #[serde(default)] + pub max_in_flight: Option, +} + +/// Built-in audit trail hook that logs lifecycle events. +struct AuditLogHook; + +#[async_trait] +impl Hook for AuditLogHook { + fn name(&self) -> &str { + "builtin.audit_log" + } + + fn hook_points(&self) -> &[HookPoint] { + &ALL_HOOK_POINTS + } + + async fn execute( + &self, + event: &HookEvent, + _ctx: &HookContext, + ) -> Result { + tracing::debug!( + target: "hooks::audit", + hook = self.name(), + point = event.hook_point().as_str(), + user_id = %event_user_id(event), + "Lifecycle hook event" + ); + + Ok(HookOutcome::ok()) + } +} + +#[derive(Debug, Clone)] +struct CompiledReplacement { + regex: Regex, + replacement: String, +} + +/// Runtime hook compiled from [`HookRuleConfig`]. +#[derive(Debug)] +struct RuleHook { + name: String, + points: Vec, + failure_mode: HookFailureMode, + timeout: Duration, + when_regex: Option, + reject_reason: Option, + replacements: Vec, + prepend: Option, + append: Option, +} + +impl RuleHook { + fn from_config(source: &str, config: HookRuleConfig) -> Result<(Self, u32), HookBundleError> { + let scoped_name = format!("{}::{}", source, config.name); + + if config.points.is_empty() { + return Err(HookBundleError::MissingHookPoints { hook: scoped_name }); + } + + let timeout = timeout_from_ms(config.timeout_ms, &scoped_name)?; + + let when_regex = match config.when_regex { + Some(pattern) => { + Some( + Regex::new(&pattern).map_err(|e| HookBundleError::InvalidRegex { + hook: scoped_name.clone(), + pattern, + reason: e.to_string(), + })?, + ) + } + None => None, + }; + + let mut replacements = Vec::with_capacity(config.replacements.len()); + for replacement in config.replacements { + let compiled = + Regex::new(&replacement.pattern).map_err(|e| HookBundleError::InvalidRegex { + hook: scoped_name.clone(), + pattern: replacement.pattern.clone(), + reason: e.to_string(), + })?; + + replacements.push(CompiledReplacement { + regex: compiled, + replacement: replacement.replacement, + }); + } + + if when_regex.is_some() + && config.reject_reason.is_none() + && replacements.is_empty() + && config.prepend.as_deref().is_none() + && config.append.as_deref().is_none() + { + tracing::warn!( + hook = %scoped_name, + "Rule hook has a guard but no actions; it will always no-op" + ); + } + + let hook = Self { + name: scoped_name, + points: config.points, + failure_mode: config.failure_mode.unwrap_or(HookFailureMode::FailOpen), + timeout, + when_regex, + reject_reason: config.reject_reason, + replacements, + prepend: config.prepend, + append: config.append, + }; + + Ok((hook, config.priority.unwrap_or(DEFAULT_RULE_PRIORITY))) + } +} + +#[async_trait] +impl Hook for RuleHook { + fn name(&self) -> &str { + &self.name + } + + fn hook_points(&self) -> &[HookPoint] { + &self.points + } + + fn failure_mode(&self) -> HookFailureMode { + self.failure_mode + } + + fn timeout(&self) -> Duration { + self.timeout + } + + async fn execute( + &self, + event: &HookEvent, + _ctx: &HookContext, + ) -> Result { + let content = extract_primary_content(event); + + if let Some(ref guard) = self.when_regex + && !guard.is_match(&content) + { + return Ok(HookOutcome::ok()); + } + + if let Some(ref reason) = self.reject_reason { + return Ok(HookOutcome::reject(reason.clone())); + } + + let mut modified = content.clone(); + + for replacement in &self.replacements { + modified = replacement + .regex + .replace_all(&modified, replacement.replacement.as_str()) + .into_owned(); + } + + if let Some(ref prefix) = self.prepend { + modified = format!("{}{}", prefix, modified); + } + + if let Some(ref suffix) = self.append { + modified.push_str(suffix); + } + + if modified != content { + Ok(HookOutcome::modify(modified)) + } else { + Ok(HookOutcome::ok()) + } + } +} + +/// Runtime outbound webhook hook. +#[derive(Debug)] +struct OutboundWebhookHook { + name: String, + points: Vec, + client: reqwest::Client, + url: String, + headers: HeaderMap, + timeout: Duration, + semaphore: Arc, +} + +impl OutboundWebhookHook { + fn from_config( + source: &str, + config: OutboundWebhookConfig, + ) -> Result<(Self, u32), HookBundleError> { + let scoped_name = format!("{}::{}", source, config.name); + + if config.points.is_empty() { + return Err(HookBundleError::MissingHookPoints { hook: scoped_name }); + } + + let url = validate_webhook_url(&scoped_name, &config.url)?; + let headers = validate_webhook_headers(&scoped_name, &config.headers)?; + + let timeout = timeout_from_ms( + config.timeout_ms.or(Some(DEFAULT_WEBHOOK_TIMEOUT_MS)), + &scoped_name, + )?; + + let max_in_flight = config + .max_in_flight + .unwrap_or(DEFAULT_WEBHOOK_MAX_IN_FLIGHT); + if max_in_flight == 0 { + return Err(HookBundleError::InvalidWebhookMaxInFlight { hook: scoped_name }); + } + + let client = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| HookBundleError::InvalidFormat(e.to_string()))?; + + let hook = Self { + name: scoped_name, + points: config.points, + client, + url: url.to_string(), + headers, + timeout, + semaphore: Arc::new(Semaphore::new(max_in_flight)), + }; + + Ok((hook, config.priority.unwrap_or(DEFAULT_WEBHOOK_PRIORITY))) + } +} + +#[derive(Debug, Serialize)] +struct OutboundWebhookPayload { + hook: String, + point: String, + timestamp: String, + event: OutboundWebhookEventSummary, + metadata_present: bool, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum OutboundWebhookEventSummary { + Inbound { + channel: String, + has_thread_id: bool, + content_length: usize, + }, + ToolCall { + tool_name: String, + context: String, + parameter_count: usize, + }, + Outbound { + channel: String, + has_thread_id: bool, + content_length: usize, + }, + SessionStart, + SessionEnd, + ResponseTransform { + response_length: usize, + }, +} + +#[async_trait] +impl Hook for OutboundWebhookHook { + fn name(&self) -> &str { + &self.name + } + + fn hook_points(&self) -> &[HookPoint] { + &self.points + } + + fn timeout(&self) -> Duration { + self.timeout + } + + async fn execute( + &self, + event: &HookEvent, + ctx: &HookContext, + ) -> Result { + let payload = OutboundWebhookPayload { + hook: self.name.clone(), + point: event.hook_point().as_str().to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + event: summarize_webhook_event(event), + metadata_present: !ctx.metadata.is_null(), + }; + + let permit = match self.semaphore.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + tracing::warn!( + hook = %self.name, + "Dropping outbound webhook delivery due to concurrency limit" + ); + return Ok(HookOutcome::ok()); + } + }; + + let base_client = self.client.clone(); + let url = self.url.clone(); + let headers = self.headers.clone(); + let hook_name = self.name.clone(); + let timeout = self.timeout; + + tokio::spawn(async move { + let _permit = permit; + + let client = match dispatch_client_for_target(&base_client, &url, timeout).await { + Ok(client) => client, + Err(err) => { + tracing::warn!( + hook = %hook_name, + error = %err, + "Outbound webhook target blocked by runtime network policy" + ); + return; + } + }; + + let request = client.post(url).headers(headers).json(&payload); + + if let Err(err) = request.send().await { + tracing::warn!( + hook = %hook_name, + error = %err, + "Outbound webhook delivery failed" + ); + } + }); + + Ok(HookOutcome::ok()) + } +} + +fn summarize_webhook_event(event: &HookEvent) -> OutboundWebhookEventSummary { + match event { + HookEvent::Inbound { + channel, + content, + thread_id, + .. + } => OutboundWebhookEventSummary::Inbound { + channel: channel.clone(), + has_thread_id: thread_id.is_some(), + content_length: content.len(), + }, + HookEvent::ToolCall { + tool_name, + context, + parameters, + .. + } => OutboundWebhookEventSummary::ToolCall { + tool_name: tool_name.clone(), + context: context.clone(), + parameter_count: match parameters { + serde_json::Value::Object(map) => map.len(), + serde_json::Value::Null => 0, + _ => 1, + }, + }, + HookEvent::Outbound { + channel, + content, + thread_id, + .. + } => OutboundWebhookEventSummary::Outbound { + channel: channel.clone(), + has_thread_id: thread_id.is_some(), + content_length: content.len(), + }, + HookEvent::SessionStart { .. } => OutboundWebhookEventSummary::SessionStart, + HookEvent::SessionEnd { .. } => OutboundWebhookEventSummary::SessionEnd, + HookEvent::ResponseTransform { response, .. } => { + OutboundWebhookEventSummary::ResponseTransform { + response_length: response.len(), + } + } + } +} + +fn validate_webhook_url(hook_name: &str, url: &str) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|_| HookBundleError::InvalidWebhookUrl { + hook: hook_name.to_string(), + url: url.to_string(), + })?; + + if parsed.scheme() != "https" { + return Err(HookBundleError::InvalidWebhookScheme { + hook: hook_name.to_string(), + scheme: parsed.scheme().to_string(), + }); + } + + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(HookBundleError::InvalidWebhookUrl { + hook: hook_name.to_string(), + url: url.to_string(), + }); + } + + if let Some(host) = parsed.host_str() { + let normalized_host = normalize_host(host); + + if let Ok(ip) = normalized_host.parse::() { + if is_forbidden_ip(ip) { + return Err(HookBundleError::ForbiddenWebhookHost { + hook: hook_name.to_string(), + host: normalized_host.to_string(), + }); + } + } else if is_forbidden_webhook_host(normalized_host) { + return Err(HookBundleError::ForbiddenWebhookHost { + hook: hook_name.to_string(), + host: normalized_host.to_string(), + }); + } + } + + Ok(parsed) +} + +async fn dispatch_client_for_target( + base_client: &reqwest::Client, + url: &str, + timeout: Duration, +) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|e| format!("Invalid URL: {e}"))?; + let host = parsed + .host_str() + .ok_or_else(|| "Webhook URL has no host".to_string())?; + let normalized_host = normalize_host(host); + + if let Ok(ip) = normalized_host.parse::() { + if is_forbidden_ip(ip) { + return Err(format!("Webhook target resolves to blocked IP {ip}")); + } + return Ok(base_client.clone()); + } + + let port = parsed + .port_or_known_default() + .ok_or_else(|| "Webhook URL has no valid port".to_string())?; + + let addrs: Vec = tokio::net::lookup_host((normalized_host, port)) + .await + .map_err(|e| format!("DNS resolution failed: {e}"))? + .collect(); + + if addrs.is_empty() { + return Err("DNS resolution returned no addresses".to_string()); + } + + for addr in &addrs { + if is_forbidden_ip(addr.ip()) { + return Err(format!( + "Webhook target resolves to blocked IP {}", + addr.ip() + )); + } + } + + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .resolve_to_addrs(normalized_host, &addrs) + .build() + .map_err(|e| format!("Failed to build resolved webhook client: {e}")) +} + +fn normalize_host(host: &str) -> &str { + host.trim_start_matches('[').trim_end_matches(']') +} + +fn validate_webhook_headers( + hook_name: &str, + headers: &HashMap, +) -> Result { + let mut validated = HeaderMap::new(); + + for (name, value) in headers { + let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| { + HookBundleError::InvalidWebhookHeader { + hook: hook_name.to_string(), + header: name.clone(), + reason: e.to_string(), + } + })?; + + if is_forbidden_header(header_name.as_str()) { + return Err(HookBundleError::ForbiddenWebhookHeader { + hook: hook_name.to_string(), + header: name.clone(), + }); + } + + let header_value = + HeaderValue::from_str(value).map_err(|e| HookBundleError::InvalidWebhookHeader { + hook: hook_name.to_string(), + header: name.clone(), + reason: e.to_string(), + })?; + + validated.insert(header_name, header_value); + } + + Ok(validated) +} + +fn is_forbidden_webhook_host(host: &str) -> bool { + let lower = host.to_ascii_lowercase(); + lower == "localhost" + || lower.ends_with(".localhost") + || lower == "host.docker.internal" + || lower == "metadata.google.internal" + || lower == "metadata.aws.internal" +} + +fn is_forbidden_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_forbidden_ipv4(v4), + IpAddr::V6(v6) => { + if let Some(mapped) = ipv6_mapped_ipv4(v6) { + return is_forbidden_ipv4(mapped); + } + + if v6.is_loopback() + || v6.is_unspecified() + || v6.is_unique_local() + || v6.is_unicast_link_local() + || v6.is_multicast() + { + return true; + } + + // Documentation range (2001:db8::/32). + let segments = v6.segments(); + segments[0] == 0x2001 && segments[1] == 0x0db8 + } + } +} + +fn ipv6_mapped_ipv4(v6: Ipv6Addr) -> Option { + let segments = v6.segments(); + if segments[0] == 0 + && segments[1] == 0 + && segments[2] == 0 + && segments[3] == 0 + && segments[4] == 0 + && segments[5] == 0xffff + { + Some(Ipv4Addr::new( + (segments[6] >> 8) as u8, + segments[6] as u8, + (segments[7] >> 8) as u8, + segments[7] as u8, + )) + } else { + None + } +} + +fn is_forbidden_ipv4(v4: Ipv4Addr) -> bool { + if v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_unspecified() + || v4.is_multicast() + { + return true; + } + + let octets = v4.octets(); + + // Carrier-grade NAT range (100.64.0.0/10). + if octets[0] == 100 && (64..=127).contains(&octets[1]) { + return true; + } + + // Benchmark testing range (198.18.0.0/15). + if octets[0] == 198 && matches!(octets[1], 18 | 19) { + return true; + } + + false +} + +fn is_forbidden_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower == "host" + || lower == "authorization" + || lower == "cookie" + || lower == "proxy-authorization" + || lower == "forwarded" + || lower == "x-real-ip" + || lower == "transfer-encoding" + || lower == "connection" + || lower.starts_with("x-forwarded-") +} + +fn timeout_from_ms(timeout_ms: Option, hook_name: &str) -> Result { + if let Some(ms) = timeout_ms { + if ms == 0 || ms > MAX_HOOK_TIMEOUT_MS { + return Err(HookBundleError::InvalidTimeout { + hook: hook_name.to_string(), + max_ms: MAX_HOOK_TIMEOUT_MS, + }); + } + Ok(Duration::from_millis(ms)) + } else { + Ok(Duration::from_secs(5)) + } +} + +fn event_user_id(event: &HookEvent) -> &str { + match event { + HookEvent::Inbound { user_id, .. } + | HookEvent::ToolCall { user_id, .. } + | HookEvent::Outbound { user_id, .. } + | HookEvent::SessionStart { user_id, .. } + | HookEvent::SessionEnd { user_id, .. } + | HookEvent::ResponseTransform { user_id, .. } => user_id, + } +} + +fn extract_primary_content(event: &HookEvent) -> String { + match event { + HookEvent::Inbound { content, .. } | HookEvent::Outbound { content, .. } => content.clone(), + HookEvent::ToolCall { parameters, .. } => { + serde_json::to_string(parameters).unwrap_or_default() + } + HookEvent::SessionStart { session_id, .. } | HookEvent::SessionEnd { session_id, .. } => { + session_id.clone() + } + HookEvent::ResponseTransform { response, .. } => response.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inbound_event(content: &str) -> HookEvent { + HookEvent::Inbound { + user_id: "user-1".to_string(), + channel: "test".to_string(), + content: content.to_string(), + thread_id: None, + } + } + + #[test] + fn test_parse_bundle_array_shorthand() { + let value = serde_json::json!([ + { + "name": "append-bang", + "points": ["beforeInbound"], + "append": "!" + } + ]); + + let parsed = HookBundleConfig::from_value(&value).unwrap(); + assert_eq!(parsed.rules.len(), 1); + assert!(parsed.outbound_webhooks.is_empty()); + } + + #[tokio::test] + async fn test_rule_hook_modifies_content() { + let registry = Arc::new(HookRegistry::new()); + + let bundle = HookBundleConfig { + rules: vec![HookRuleConfig { + name: "redact-secret".to_string(), + points: vec![HookPoint::BeforeInbound], + priority: None, + failure_mode: None, + timeout_ms: None, + when_regex: None, + reject_reason: None, + replacements: vec![RegexReplacementConfig { + pattern: "secret".to_string(), + replacement: "[redacted]".to_string(), + }], + prepend: None, + append: None, + }], + outbound_webhooks: vec![], + }; + + let summary = register_bundle(®istry, "workspace:hooks/hooks.json", bundle).await; + assert_eq!(summary.hooks, 1); + assert_eq!(summary.errors, 0); + + let result = registry + .run(&inbound_event("contains secret here")) + .await + .unwrap(); + match result { + HookOutcome::Continue { + modified: Some(value), + } => { + assert_eq!(value, "contains [redacted] here"); + } + other => panic!("expected modified output, got {other:?}"), + } + } + + #[tokio::test] + async fn test_rule_hook_rejects() { + let registry = Arc::new(HookRegistry::new()); + + let bundle = HookBundleConfig { + rules: vec![HookRuleConfig { + name: "block-forbidden".to_string(), + points: vec![HookPoint::BeforeInbound], + priority: None, + failure_mode: None, + timeout_ms: None, + when_regex: Some("forbidden".to_string()), + reject_reason: Some("forbidden content".to_string()), + replacements: vec![], + prepend: None, + append: None, + }], + outbound_webhooks: vec![], + }; + + let summary = register_bundle(®istry, "plugin:tool:test", bundle).await; + assert_eq!(summary.hooks, 1); + + let result = registry.run(&inbound_event("this is forbidden")).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + HookError::Rejected { reason } if reason == "forbidden content" + )); + } + + #[tokio::test] + async fn test_outbound_webhook_hook_registers() { + let registry = Arc::new(HookRegistry::new()); + + let bundle = HookBundleConfig { + rules: vec![], + outbound_webhooks: vec![OutboundWebhookConfig { + name: "notify".to_string(), + points: vec![HookPoint::BeforeInbound], + url: "https://example.com/hook".to_string(), + headers: HashMap::new(), + timeout_ms: Some(1000), + priority: None, + max_in_flight: None, + }], + }; + + let summary = register_bundle(®istry, "workspace:hooks/webhook.hook.json", bundle).await; + assert_eq!(summary.outbound_webhooks, 1); + + // Should return immediately regardless of webhook delivery result. + let result = registry.run(&inbound_event("hello")).await; + assert!(result.is_ok()); + } + + #[test] + fn test_timeout_from_ms_rejects_zero() { + let err = timeout_from_ms(Some(0), "hook").unwrap_err(); + assert!(matches!(err, HookBundleError::InvalidTimeout { .. })); + } + + #[test] + fn test_timeout_from_ms_rejects_above_limit() { + let err = timeout_from_ms(Some(30_001), "hook").unwrap_err(); + assert!(matches!(err, HookBundleError::InvalidTimeout { .. })); + } + + #[test] + fn test_rule_hook_requires_points() { + let config = HookRuleConfig { + name: "invalid".to_string(), + points: vec![], + priority: None, + failure_mode: None, + timeout_ms: None, + when_regex: None, + reject_reason: None, + replacements: vec![], + prepend: None, + append: None, + }; + + let err = RuleHook::from_config("workspace:hooks/hooks.json", config).unwrap_err(); + assert!(matches!(err, HookBundleError::MissingHookPoints { .. })); + } + + #[test] + fn test_invalid_webhook_scheme_rejected() { + let config = OutboundWebhookConfig { + name: "notify".to_string(), + points: vec![HookPoint::BeforeInbound], + url: "http://example.com/hook".to_string(), + headers: HashMap::new(), + timeout_ms: None, + priority: None, + max_in_flight: None, + }; + + let err = + OutboundWebhookHook::from_config("workspace:hooks/hooks.json", config).unwrap_err(); + assert!(matches!(err, HookBundleError::InvalidWebhookScheme { .. })); + } + + #[test] + fn test_private_webhook_host_rejected() { + let config = OutboundWebhookConfig { + name: "notify".to_string(), + points: vec![HookPoint::BeforeInbound], + url: "https://127.0.0.1/hook".to_string(), + headers: HashMap::new(), + timeout_ms: None, + priority: None, + max_in_flight: None, + }; + + let err = + OutboundWebhookHook::from_config("workspace:hooks/hooks.json", config).unwrap_err(); + assert!(matches!(err, HookBundleError::ForbiddenWebhookHost { .. })); + } + + #[test] + fn test_mapped_ipv4_webhook_host_rejected() { + let config = OutboundWebhookConfig { + name: "notify".to_string(), + points: vec![HookPoint::BeforeInbound], + url: "https://[::ffff:127.0.0.1]/hook".to_string(), + headers: HashMap::new(), + timeout_ms: None, + priority: None, + max_in_flight: None, + }; + + let err = + OutboundWebhookHook::from_config("workspace:hooks/hooks.json", config).unwrap_err(); + assert!(matches!(err, HookBundleError::ForbiddenWebhookHost { .. })); + } + + #[test] + fn test_restricted_webhook_header_rejected() { + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer token".to_string()); + + let config = OutboundWebhookConfig { + name: "notify".to_string(), + points: vec![HookPoint::BeforeInbound], + url: "https://example.com/hook".to_string(), + headers, + timeout_ms: None, + priority: None, + max_in_flight: None, + }; + + let err = + OutboundWebhookHook::from_config("workspace:hooks/hooks.json", config).unwrap_err(); + assert!(matches!( + err, + HookBundleError::ForbiddenWebhookHeader { .. } + )); + } + + #[test] + fn test_zero_max_in_flight_rejected() { + let config = OutboundWebhookConfig { + name: "notify".to_string(), + points: vec![HookPoint::BeforeInbound], + url: "https://example.com/hook".to_string(), + headers: HashMap::new(), + timeout_ms: None, + priority: None, + max_in_flight: Some(0), + }; + + let err = + OutboundWebhookHook::from_config("workspace:hooks/hooks.json", config).unwrap_err(); + assert!(matches!( + err, + HookBundleError::InvalidWebhookMaxInFlight { .. } + )); + } + + #[tokio::test] + async fn test_runtime_target_validation_blocks_private_ip() { + let base_client = reqwest::Client::builder().build().unwrap(); + let err = dispatch_client_for_target( + &base_client, + "https://127.0.0.1/hook", + Duration::from_secs(1), + ) + .await + .unwrap_err(); + assert!(err.contains("blocked IP")); + } + + #[tokio::test] + async fn test_runtime_target_validation_allows_public_ip() { + let base_client = reqwest::Client::builder().build().unwrap(); + let result = dispatch_client_for_target( + &base_client, + "https://1.1.1.1/hook", + Duration::from_secs(1), + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_rule_guard_no_match_is_passthrough() { + let registry = Arc::new(HookRegistry::new()); + + let bundle = HookBundleConfig { + rules: vec![HookRuleConfig { + name: "guarded-rewrite".to_string(), + points: vec![HookPoint::BeforeInbound], + priority: None, + failure_mode: None, + timeout_ms: None, + when_regex: Some("forbidden".to_string()), + reject_reason: None, + replacements: vec![RegexReplacementConfig { + pattern: "hello".to_string(), + replacement: "hi".to_string(), + }], + prepend: None, + append: None, + }], + outbound_webhooks: vec![], + }; + + register_bundle(®istry, "workspace:hooks/hooks.json", bundle).await; + let result = registry.run(&inbound_event("hello world")).await.unwrap(); + assert!(matches!(result, HookOutcome::Continue { modified: None })); + } + + #[tokio::test] + async fn test_rule_hook_combined_actions() { + let registry = Arc::new(HookRegistry::new()); + + let bundle = HookBundleConfig { + rules: vec![HookRuleConfig { + name: "combined".to_string(), + points: vec![HookPoint::BeforeInbound], + priority: None, + failure_mode: None, + timeout_ms: None, + when_regex: None, + reject_reason: None, + replacements: vec![RegexReplacementConfig { + pattern: "secret".to_string(), + replacement: "safe".to_string(), + }], + prepend: Some("[".to_string()), + append: Some("]".to_string()), + }], + outbound_webhooks: vec![], + }; + + register_bundle(®istry, "workspace:hooks/hooks.json", bundle).await; + let result = registry.run(&inbound_event("secret")).await.unwrap(); + match result { + HookOutcome::Continue { + modified: Some(value), + } => assert_eq!(value, "[safe]"), + other => panic!("expected modified output, got {other:?}"), + } + } +} diff --git a/src/hooks/hook.rs b/src/hooks/hook.rs index 7df174e1..9c5670f3 100644 --- a/src/hooks/hook.rs +++ b/src/hooks/hook.rs @@ -3,9 +3,11 @@ use std::time::Duration; use async_trait::async_trait; +use serde::{Deserialize, Serialize}; /// Points in the agent lifecycle where hooks can be attached. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum HookPoint { /// Before processing an inbound user message. BeforeInbound, @@ -21,8 +23,22 @@ pub enum HookPoint { TransformResponse, } +impl HookPoint { + /// Human-readable hook point identifier. + pub fn as_str(&self) -> &'static str { + match self { + HookPoint::BeforeInbound => "beforeInbound", + HookPoint::BeforeToolCall => "beforeToolCall", + HookPoint::BeforeOutbound => "beforeOutbound", + HookPoint::OnSessionStart => "onSessionStart", + HookPoint::OnSessionEnd => "onSessionEnd", + HookPoint::TransformResponse => "transformResponse", + } + } +} + /// Contextual data carried with each hook invocation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum HookEvent { /// An inbound user message about to be processed. Inbound { @@ -133,7 +149,8 @@ impl HookOutcome { } /// How to handle hook execution failures. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum HookFailureMode { /// On error/timeout, continue processing as if the hook returned `ok()`. FailOpen, diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 9ea6a5ac..a33b0406 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -12,8 +12,14 @@ //! Hooks are executed in priority order (lower number = higher priority). //! Each hook can pass through, modify content, or reject the event. +pub mod bootstrap; +pub mod bundled; pub mod hook; pub mod registry; +pub use bootstrap::{HookBootstrapSummary, bootstrap_hooks}; +pub use bundled::{ + HookBundleConfig, HookRegistrationSummary, register_bundle, register_bundled_hooks, +}; pub use hook::{Hook, HookContext, HookError, HookEvent, HookFailureMode, HookOutcome, HookPoint}; pub use registry::HookRegistry; diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 6148d954..d20788bb 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -39,7 +39,22 @@ impl HookRegistry { /// Lower priority number = runs first. pub async fn register_with_priority(&self, hook: Arc, priority: u32) { let mut hooks = self.hooks.write().await; - hooks.push(HookEntry { hook, priority }); + let hook_name = hook.name().to_string(); + + if let Some(existing) = hooks + .iter_mut() + .find(|entry| entry.hook.name() == hook_name) + { + tracing::warn!( + hook = %hook_name, + "Replacing existing hook registration with same name" + ); + existing.hook = hook; + existing.priority = priority; + } else { + hooks.push(HookEntry { hook, priority }); + } + hooks.sort_by_key(|e| e.priority); } @@ -346,6 +361,44 @@ mod tests { assert_eq!(names, vec!["hook-a", "hook-b"]); } + #[tokio::test] + async fn test_register_duplicate_name_replaces_existing() { + let registry = HookRegistry::new(); + + registry + .register_with_priority( + Arc::new(ModifyHook { + name: "dup".into(), + suffix: "-A".into(), + points: vec![HookPoint::BeforeInbound], + }), + 100, + ) + .await; + + registry + .register_with_priority( + Arc::new(ModifyHook { + name: "dup".into(), + suffix: "-B".into(), + points: vec![HookPoint::BeforeInbound], + }), + 10, + ) + .await; + + let names = registry.list().await; + assert_eq!(names, vec!["dup"]); + + let result = registry.run(&test_event()).await.unwrap(); + match result { + HookOutcome::Continue { + modified: Some(value), + } => assert_eq!(value, "hello-B"), + other => panic!("expected modified output, got {other:?}"), + } + } + #[tokio::test] async fn test_priority_ordering() { let registry = HookRegistry::new(); diff --git a/src/main.rs b/src/main.rs index 15df6e3f..7d47dcc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use ironclaw::{ config::Config, context::ContextManager, extensions::ExtensionManager, - hooks::HookRegistry, + hooks::{HookRegistry, bootstrap_hooks}, llm::{ CachedProvider, CircuitBreakerConfig, CircuitBreakerProvider, CooldownConfig, FailoverProvider, LlmProvider, ResponseCacheConfig, SessionConfig, @@ -746,6 +746,9 @@ async fn main() -> anyhow::Result<()> { let mcp_session_manager = Arc::new(McpSessionManager::new()); + // Create hook registry early so runtime extension activation can register hooks. + let hooks = Arc::new(HookRegistry::new()); + // Create WASM tool runtime (sync, just builds the wasmtime engine) let wasm_tool_runtime: Option> = if config.wasm.enabled && config.wasm.tools_dir.exists() { @@ -763,6 +766,8 @@ async fn main() -> anyhow::Result<()> { // Load WASM tools and MCP servers concurrently. // Both register into the shared ToolRegistry (RwLock-based) so concurrent writes are safe. let wasm_tools_future = async { + let mut dev_loaded_tool_names: Vec = Vec::new(); + if let Some(ref runtime) = wasm_tool_runtime { let mut loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools)); if let Some(ref secrets) = secrets_store { @@ -791,6 +796,7 @@ async fn main() -> anyhow::Result<()> { // Load dev tools from build artifacts (overrides installed if newer) match load_dev_tools(&loader, &config.wasm.tools_dir).await { Ok(results) => { + dev_loaded_tool_names.extend(results.loaded.iter().cloned()); if !results.loaded.is_empty() { tracing::info!( "Loaded {} dev WASM tools from build artifacts", @@ -803,6 +809,8 @@ async fn main() -> anyhow::Result<()> { } } } + + dev_loaded_tool_names }; let mcp_servers_future = async { @@ -908,7 +916,7 @@ async fn main() -> anyhow::Result<()> { } }; - tokio::join!(wasm_tools_future, mcp_servers_future); + let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future); // Create extension manager for in-chat discovery/install/auth/activate let extension_manager = if let Some(ref secrets) = secrets_store { @@ -916,6 +924,7 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&mcp_session_manager), Arc::clone(secrets), Arc::clone(&tools), + Some(Arc::clone(&hooks)), wasm_tool_runtime.clone(), config.wasm.tools_dir.clone(), config.channels.wasm_channels_dir.clone(), @@ -1013,6 +1022,7 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); let mut channel_names: Vec = Vec::new(); + let mut loaded_wasm_channel_names: Vec = Vec::new(); if let Some(repl) = repl_channel { channels.add(Box::new(repl)); @@ -1045,6 +1055,7 @@ async fn main() -> anyhow::Result<()> { for loaded in results.loaded { let channel_name = loaded.name().to_string(); + loaded_wasm_channel_names.push(channel_name.clone()); tracing::info!("Loaded WASM channel: {}", channel_name); let secret_name = loaded.webhook_secret_name(); @@ -1270,8 +1281,27 @@ async fn main() -> anyhow::Result<()> { // Create context manager (shared between job tools and agent) let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs)); - // Create hook registry - let hooks = Arc::new(HookRegistry::new()); + // Register bundled/plugin/workspace hooks. + let active_tool_names = tools.list().await; + + let hook_bootstrap = bootstrap_hooks( + &hooks, + workspace.as_ref(), + &config.wasm.tools_dir, + &config.channels.wasm_channels_dir, + &active_tool_names, + &loaded_wasm_channel_names, + &dev_loaded_tool_names, + ) + .await; + tracing::info!( + bundled = hook_bootstrap.bundled_hooks, + plugin = hook_bootstrap.plugin_hooks, + workspace = hook_bootstrap.workspace_hooks, + outbound_webhooks = hook_bootstrap.outbound_webhooks, + errors = hook_bootstrap.errors, + "Lifecycle hooks initialized" + ); // Create session manager (shared between agent and web gateway) let session_manager = Arc::new(SessionManager::new().with_hooks(hooks.clone())); diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index a7909195..1893f705 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -570,6 +570,7 @@ mod tests { Arc::new(InMemorySecretsStore::new(crypto)), Arc::new(ToolRegistry::new()), None, + None, std::path::PathBuf::from("/tmp/ironclaw-test-tools"), std::path::PathBuf::from("/tmp/ironclaw-test-channels"), None,