mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
Full rename of all identifiers, filenames, and references: ironclaw → optimclaw IronClaw → OptimClaw IRONCLAW → OPTIMCLAW ironclaw_common → optimclaw_common ironclaw_safety → optimclaw_safety Upstream: nearai/ironclaw
1235 lines
38 KiB
Rust
1235 lines
38 KiB
Rust
//! 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<HookRuleConfig>,
|
|
/// Fire-and-forget webhook notifications on selected hook points.
|
|
#[serde(default)]
|
|
pub outbound_webhooks: Vec<OutboundWebhookConfig>,
|
|
}
|
|
|
|
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<Self, HookBundleError> {
|
|
if value.is_array() {
|
|
let rules: Vec<HookRuleConfig> = 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 OptimClaw.
|
|
pub async fn register_bundled_hooks(registry: &Arc<HookRegistry>) -> 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<HookRegistry>,
|
|
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<HookPoint>,
|
|
/// Optional priority override (lower runs first).
|
|
#[serde(default)]
|
|
pub priority: Option<u32>,
|
|
/// Failure handling mode (default fail_open).
|
|
#[serde(default)]
|
|
pub failure_mode: Option<HookFailureMode>,
|
|
/// Optional timeout override for this hook in milliseconds.
|
|
#[serde(default)]
|
|
pub timeout_ms: Option<u64>,
|
|
/// Optional regex guard. If provided and no match, rule is a no-op.
|
|
#[serde(default)]
|
|
pub when_regex: Option<String>,
|
|
/// Optional immediate reject reason if guard matches.
|
|
#[serde(default)]
|
|
pub reject_reason: Option<String>,
|
|
/// Regex replacements applied in order.
|
|
#[serde(default)]
|
|
pub replacements: Vec<RegexReplacementConfig>,
|
|
/// Text prepended to the event's primary content.
|
|
#[serde(default)]
|
|
pub prepend: Option<String>,
|
|
/// Text appended to the event's primary content.
|
|
#[serde(default)]
|
|
pub append: Option<String>,
|
|
}
|
|
|
|
/// 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<HookPoint>,
|
|
/// Target URL.
|
|
pub url: String,
|
|
/// Optional static headers.
|
|
#[serde(default)]
|
|
pub headers: HashMap<String, String>,
|
|
/// Optional timeout override in milliseconds.
|
|
#[serde(default)]
|
|
pub timeout_ms: Option<u64>,
|
|
/// Optional priority override (lower runs first).
|
|
#[serde(default)]
|
|
pub priority: Option<u32>,
|
|
/// Optional max number of concurrent in-flight deliveries.
|
|
#[serde(default)]
|
|
pub max_in_flight: Option<usize>,
|
|
}
|
|
|
|
/// 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<HookOutcome, HookError> {
|
|
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<HookPoint>,
|
|
failure_mode: HookFailureMode,
|
|
timeout: Duration,
|
|
when_regex: Option<Regex>,
|
|
reject_reason: Option<String>,
|
|
replacements: Vec<CompiledReplacement>,
|
|
prepend: Option<String>,
|
|
append: Option<String>,
|
|
}
|
|
|
|
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<HookOutcome, HookError> {
|
|
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<HookPoint>,
|
|
client: reqwest::Client,
|
|
url: String,
|
|
headers: HeaderMap,
|
|
timeout: Duration,
|
|
semaphore: Arc<Semaphore>,
|
|
}
|
|
|
|
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<HookOutcome, HookError> {
|
|
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<reqwest::Url, HookBundleError> {
|
|
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::<IpAddr>() {
|
|
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<reqwest::Client, String> {
|
|
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::<IpAddr>() {
|
|
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<SocketAddr> = 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<String, String>,
|
|
) -> Result<HeaderMap, HookBundleError> {
|
|
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<Ipv4Addr> {
|
|
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<u64>, hook_name: &str) -> Result<Duration, HookBundleError> {
|
|
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:?}"),
|
|
}
|
|
}
|
|
}
|