Add generic host-verified /webhook/tools/{tool} ingress (#757)

* Add generic host-verified webhook ingress for tools

* Stabilize trace E2E test rig and approval behavior

* Fix webhook security issues from review feedback

- Reject tools without webhook_capability() (was unauthenticated RCE)
- Remove secret-in-query-string fallback (leak via logs/referrers)
- Require approval for event_emit tool (escalation via routine triggers)
- Simplify header_value() (HeaderMap already case-insensitive)
- Redact internal errors from webhook HTTP responses
- Remove unused hmac_timestamp_tolerance_secs field
- Add regression test for tool without webhook capability

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Harden webhook ingress: require auth mechanism, body limit layer, health check

- Reject webhook capabilities that declare no auth mechanism (empty
  WebhookCapability would previously allow unauthenticated access)
- Add DefaultBodyLimit layer to reject oversized payloads before buffering
- Health check (GET) now verifies tool has webhook_capability(), not just
  existence
- Add regression tests for all three fixes

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix auto_approve_tools inconsistency between dispatcher and thread_ops

dispatcher.rs skips all approval checks (including Always) when
auto_approve_tools is true, but thread_ops.rs still required approval
for Always tools. This caused deferred tool calls to unexpectedly halt
in test rigs and auto-approve configurations.

Match dispatcher behavior: short-circuit all approval when
auto_approve_tools is enabled.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-11 03:36:25 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 55b5a462a2
commit 369741fc60
17 changed files with 989 additions and 26 deletions
+2 -1
View File
@@ -440,6 +440,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
| `onSessionStart` hook | ✅ | ✅ | P2 | |
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
@@ -558,7 +559,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ❌ Media handling (images, PDFs)
- ✅ Ollama/local model support (via rig::providers::ollama)
- ❌ Configuration hot-reload
- ❌ Webhook trigger endpoint in web gateway
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
- ❌ Channel health monitor with auto-restart
- ❌ Partial output preservation on abort
+13 -7
View File
@@ -925,14 +925,20 @@ impl Agent {
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools {
false
} else {
use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
}
ApprovalRequirement::Always => true,
};
if needs_approval {
+46
View File
@@ -106,6 +106,34 @@ pub fn verify_slack_signature(
.into()
}
/// Verify raw-body HMAC-SHA256 signature with a configurable prefix.
///
/// Computes `HMAC-SHA256(secret, body)` and compares against
/// `prefix + hex_digest` in constant time.
pub fn verify_hmac_sha256_prefixed(
secret: &str,
body: &[u8],
signature_header: &str,
prefix: &str,
) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let computed = mac.finalize().into_bytes();
let computed_hex = hex::encode(computed);
let expected = format!("{prefix}{computed_hex}");
expected
.as_bytes()
.ct_eq(signature_header.as_bytes())
.into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -498,6 +526,24 @@ mod tests {
);
}
#[test]
fn test_hmac_sha256_prefixed_valid() {
let secret = "github-secret";
let body = br#"{"action":"opened"}"#;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(body);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
assert!(!verify_hmac_sha256_prefixed(
secret,
body,
"sha256=deadbeef",
"sha256="
));
}
#[test]
fn test_slack_stale_timestamp_rejected() {
let signing_secret = "my-signing-secret";
+6
View File
@@ -244,6 +244,12 @@ impl GatewayChannel {
self
}
/// Inject a shared routine engine slot used by other HTTP ingress paths.
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
self.rebuild_state(|s| s.routine_engine = slot);
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
+1
View File
@@ -74,6 +74,7 @@ pub mod tracing_fmt;
pub mod transcription;
pub mod tunnel;
pub mod util;
pub mod webhooks;
pub mod worker;
pub mod workspace;
+19 -6
View File
@@ -24,6 +24,7 @@ use ironclaw::{
orchestrator::{ReaperConfig, SandboxReaper},
pairing::PairingStore,
tracing_fmt::{init_cli_tracing, init_worker_tracing},
webhooks::{self, ToolWebhookState},
};
#[cfg(any(feature = "postgres", feature = "libsql"))]
@@ -277,9 +278,25 @@ async fn async_main() -> anyhow::Result<()> {
}
}
// Shared routine engine slot for gateway + generic webhook ingress.
let shared_routine_engine_slot: ironclaw::channels::web::server::RoutineEngineSlot =
Arc::new(tokio::sync::RwLock::new(None));
// Collect webhook route fragments; a single WebhookServer hosts them all.
let mut webhook_routes: Vec<axum::Router> = Vec::new();
webhook_routes.push(webhooks::routes(ToolWebhookState {
tools: Arc::clone(&components.tools),
routine_engine: Arc::clone(&shared_routine_engine_slot),
user_id: config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string()),
secrets_store: components.secrets_store.clone(),
}));
// Load WASM channels and register their webhook routes.
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
@@ -431,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> {
let mut sse_sender: Option<
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
> = None;
let mut routine_engine_slot: Option<ironclaw::channels::web::server::RoutineEngineSlot> = None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw =
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
@@ -455,6 +471,7 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_job_manager(Arc::clone(jm));
}
gw = gw.with_scheduler(scheduler_slot.clone());
gw = gw.with_routine_engine_slot(Arc::clone(&shared_routine_engine_slot));
if let Some(ref sr) = components.skill_registry {
gw = gw.with_skill_registry(Arc::clone(sr));
}
@@ -489,8 +506,6 @@ async fn async_main() -> anyhow::Result<()> {
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
// creates a new SseManager, which would orphan this sender.
sse_sender = Some(gw.state().sse.sender());
routine_engine_slot = Some(Arc::clone(&gw.state().routine_engine));
channel_names.push("gateway".to_string());
channels.add(Box::new(gw)).await;
}
@@ -689,9 +704,7 @@ async fn async_main() -> anyhow::Result<()> {
}
// Give the agent the routine engine slot so it can expose the engine to the gateway.
if let Some(slot) = routine_engine_slot {
agent.set_routine_engine_slot(slot);
}
agent.set_routine_engine_slot(shared_routine_engine_slot);
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
// Broadcast channel for clean shutdown of background tasks
+8
View File
@@ -328,6 +328,14 @@ pub trait Tool: Send + Sync {
None
}
/// Optional host-side webhook verification configuration for this tool.
///
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
/// before invoking the tool. Tools should then only handle payload normalization.
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
None
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
ToolSchema {
+22
View File
@@ -32,6 +32,8 @@ pub struct Capabilities {
pub tool_invoke: Option<ToolInvokeCapability>,
/// Check if secrets exist.
pub secrets: Option<SecretsCapability>,
/// Webhook authentication and signature verification.
pub webhook: Option<WebhookCapability>,
}
impl Capabilities {
@@ -308,6 +310,25 @@ impl SecretsCapability {
/// WASM capabilities use it to configure per-tool HTTP request limits.
pub use crate::tools::tool::ToolRateLimitConfig as RateLimitConfig;
/// Webhook auth/signature capability configuration for tools.
#[derive(Debug, Clone, Default)]
pub struct WebhookCapability {
/// Optional header name for shared-secret validation.
pub secret_header: Option<String>,
/// Secret name in secrets store for shared-secret validation.
pub secret_name: Option<String>,
/// Secret name in secrets store containing Ed25519 public key (Discord-style).
pub signature_key_secret_name: Option<String>,
/// Secret name in secrets store for HMAC-SHA256 signing validation.
pub hmac_secret_name: Option<String>,
/// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature).
pub hmac_signature_header: Option<String>,
/// Optional timestamp header. When present, Slack-style v0 signature is used.
pub hmac_timestamp_header: Option<String>,
/// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode).
pub hmac_prefix: Option<String>,
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
@@ -319,6 +340,7 @@ mod tests {
assert!(caps.http.is_none());
assert!(caps.tool_invoke.is_none());
assert!(caps.secrets.is_none());
assert!(caps.webhook.is_none());
}
#[test]
+72 -1
View File
@@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
};
/// Root schema for a capabilities JSON file.
@@ -65,6 +65,10 @@ pub struct CapabilitiesFile {
#[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>,
/// Tool webhook authentication/signature configuration.
#[serde(default)]
pub webhook: Option<WebhookCapabilitySchema>,
/// Authentication setup instructions.
/// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)]
@@ -107,6 +111,7 @@ impl CapabilitiesFile {
self.secrets = self.secrets.or(inner.secrets);
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
self.workspace = self.workspace.or(inner.workspace);
self.webhook = self.webhook.or(inner.webhook);
self.auth = self.auth.or(inner.auth);
self.setup = self.setup.or(inner.setup);
}
@@ -198,6 +203,10 @@ impl CapabilitiesFile {
});
}
if let Some(webhook) = &self.webhook {
caps.webhook = Some(webhook.to_webhook_capability());
}
caps
}
}
@@ -419,6 +428,46 @@ pub struct WorkspaceCapabilitySchema {
pub allowed_prefixes: Vec<String>,
}
/// Webhook capability schema for tools.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebhookCapabilitySchema {
/// HTTP header name for secret validation.
#[serde(default)]
pub secret_header: Option<String>,
/// Secret name in secrets store for shared-secret validation.
#[serde(default)]
pub secret_name: Option<String>,
/// Secret name in secrets store containing Ed25519 public key.
#[serde(default)]
pub signature_key_secret_name: Option<String>,
/// Secret name in secrets store for HMAC-SHA256 signing.
#[serde(default)]
pub hmac_secret_name: Option<String>,
/// Signature header for HMAC verification.
#[serde(default)]
pub hmac_signature_header: Option<String>,
/// Optional timestamp header for Slack-style v0 verification.
#[serde(default)]
pub hmac_timestamp_header: Option<String>,
/// Optional signature prefix for body-only HMAC mode (default sha256=).
#[serde(default)]
pub hmac_prefix: Option<String>,
}
impl WebhookCapabilitySchema {
fn to_webhook_capability(&self) -> WebhookCapability {
WebhookCapability {
secret_header: self.secret_header.clone(),
secret_name: self.secret_name.clone(),
signature_key_secret_name: self.signature_key_secret_name.clone(),
hmac_secret_name: self.hmac_secret_name.clone(),
hmac_signature_header: self.hmac_signature_header.clone(),
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
hmac_prefix: self.hmac_prefix.clone(),
}
}
}
/// Authentication setup schema.
///
/// Tools declare their auth requirements here. The agent uses this to provide
@@ -769,6 +818,28 @@ mod tests {
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
}
#[test]
fn test_parse_webhook_capability() {
let json = r#"{
"webhook": {
"hmac_secret_name": "github_webhook_secret",
"hmac_signature_header": "x-hub-signature-256",
"hmac_prefix": "sha256="
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let webhook = caps.webhook.unwrap();
assert_eq!(
webhook.hmac_secret_name.as_deref(),
Some("github_webhook_secret")
);
assert_eq!(
webhook.hmac_signature_header.as_deref(),
Some("x-hub-signature-256")
);
}
#[test]
fn test_to_capabilities() {
let json = r#"{
+1 -1
View File
@@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
// Capabilities (V2)
pub use capabilities::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader,
};
// Security components (V2)
+4
View File
@@ -808,6 +808,10 @@ impl Tool for WasmToolWrapper {
// Use the timeout as a conservative estimate
Some(self.prepared.limits.timeout)
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
self.capabilities.webhook.clone()
}
}
impl std::fmt::Debug for WasmToolWrapper {
+712
View File
@@ -0,0 +1,712 @@
//! Generic webhook ingress for tools.
//!
//! Exposes `/webhook/tools/{tool}` so external webhook providers can POST
//! payloads that are normalized by the target tool into `system_event`s.
use std::collections::HashMap;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Path, Query, State},
http::{HeaderMap, Method, StatusCode},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext;
use crate::secrets::SecretsStore;
use crate::tools::ToolRegistry;
/// Shared routine engine slot, populated by Agent after startup.
pub type RoutineEngineSlot = Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>>;
/// Shared state for the generic tools webhook ingress.
#[derive(Clone)]
pub struct ToolWebhookState {
pub tools: Arc<ToolRegistry>,
pub routine_engine: RoutineEngineSlot,
pub user_id: String,
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
#[derive(Debug, Serialize)]
struct ToolWebhookResponse {
status: &'static str,
tool: String,
emitted_events: usize,
fired_routines: usize,
}
#[derive(Debug, Deserialize)]
struct ToolWebhookOutput {
#[serde(default)]
emit_events: Vec<SystemEventIntent>,
}
#[derive(Debug, Deserialize)]
struct SystemEventIntent {
source: String,
event_type: String,
#[serde(default)]
payload: serde_json::Value,
}
const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024;
/// Build routes for tool-driven webhook ingestion.
pub fn routes(state: ToolWebhookState) -> Router {
Router::new()
.route("/webhook/tools/{tool}", post(tool_webhook_handler))
.route(
"/webhook/tools/{tool}/{*rest}",
post(tool_webhook_with_rest_handler),
)
.route("/webhook/tools/{tool}", get(tool_webhook_health))
.layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES))
.with_state(state)
}
async fn tool_webhook_health(
Path(tool): Path<String>,
State(state): State<ToolWebhookState>,
) -> (StatusCode, Json<serde_json::Value>) {
let Some(tool_impl) = state.tools.get(&tool).await else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
);
};
if tool_impl.webhook_capability().is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })),
);
}
(
StatusCode::OK,
Json(serde_json::json!({ "status": "ok", "tool": tool })),
)
}
async fn tool_webhook_handler(
Path(tool): Path<String>,
State(state): State<ToolWebhookState>,
method: Method,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await
}
async fn tool_webhook_with_rest_handler(
Path((tool, rest)): Path<(String, String)>,
State(state): State<ToolWebhookState>,
method: Method,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await
}
async fn tool_webhook_handler_inner(
tool: String,
rest: Option<String>,
state: ToolWebhookState,
method: Method,
headers: HeaderMap,
query: HashMap<String, String>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
if body.len() > MAX_WEBHOOK_BODY_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES)
})),
);
}
let Some(tool_impl) = state.tools.get(&tool).await else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
);
};
if let Err(msg) = validate_webhook_auth(
&*tool_impl,
state.secrets_store.as_deref(),
&state.user_id,
&headers,
&body,
)
.await
{
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": msg })),
);
}
let body_json: Option<serde_json::Value> = serde_json::from_slice(&body).ok();
let headers_map: HashMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_string(), v.to_string()))
})
.collect();
let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) {
format!("/webhook/tools/{tool}/{rest}")
} else {
format!("/webhook/tools/{tool}")
};
let params = serde_json::json!({
"action": "handle_webhook",
"webhook": {
"method": method.as_str(),
"path": path,
"query": query,
"headers": headers_map,
"body_json": body_json,
"body_raw": String::from_utf8_lossy(&body),
}
});
let ctx = JobContext::with_user(
state.user_id.clone(),
format!("webhook:{tool}"),
"Process external webhook",
);
let output = match tool_impl.execute(params, &ctx).await {
Ok(out) => out,
Err(e) => {
tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Tool execution failed" })),
);
}
};
let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) {
Ok(v) => v,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)"
})),
);
}
};
let emitted_events = parsed.emit_events.len();
let mut fired_routines = 0usize;
if emitted_events > 0 {
let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "Routine engine not available" })),
);
};
for event in parsed.emit_events {
fired_routines += engine
.emit_system_event(
&event.source,
&event.event_type,
&event.payload,
Some(&state.user_id),
)
.await;
}
}
let response = ToolWebhookResponse {
status: "accepted",
tool,
emitted_events,
fired_routines,
};
(StatusCode::ACCEPTED, Json(serde_json::json!(response)))
}
fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
// HeaderMap::get() already performs case-insensitive lookup per HTTP spec.
headers.get(key).and_then(|v| v.to_str().ok())
}
async fn validate_webhook_auth(
tool: &dyn crate::tools::Tool,
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
user_id: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), String> {
let Some(cfg) = tool.webhook_capability() else {
return Err(
"Tool does not declare a webhook capability; webhook access denied".to_string(),
);
};
// Require at least one authentication mechanism to be configured.
if cfg.secret_name.is_none()
&& cfg.signature_key_secret_name.is_none()
&& cfg.hmac_secret_name.is_none()
{
return Err(
"Webhook capability misconfigured: at least one auth mechanism must be configured"
.to_string(),
);
}
let Some(store) = secrets_store else {
return Err("Secrets store not available for webhook verification".to_string());
};
if let Some(secret_name) = cfg.secret_name.as_deref() {
let expected = store
.get_decrypted(user_id, secret_name)
.await
.map_err(|_| format!("Missing webhook secret '{secret_name}'"))?;
let expected = expected.expose();
let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret");
let provided = header_value(headers, secret_header)
.or_else(|| {
if secret_header != "x-webhook-secret" {
header_value(headers, "x-webhook-secret")
} else {
None
}
})
.ok_or_else(|| "Webhook secret required".to_string())?;
if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
return Err("Invalid webhook secret".to_string());
}
}
if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() {
let key = store
.get_decrypted(user_id, public_key_name)
.await
.map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?;
let key = key.expose();
let sig = header_value(headers, "x-signature-ed25519")
.ok_or_else(|| "Missing signature header".to_string())?;
let ts = header_value(headers, "x-signature-timestamp")
.ok_or_else(|| "Missing signature timestamp header".to_string())?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs)
{
return Err("Invalid signature".to_string());
}
}
if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() {
let secret = store
.get_decrypted(user_id, hmac_secret_name)
.await
.map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?;
let secret = secret.expose();
if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() {
let sig_header = cfg
.hmac_signature_header
.as_deref()
.unwrap_or("x-slack-signature");
let sig = header_value(headers, sig_header)
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
let ts = header_value(headers, timestamp_header)
.ok_or_else(|| "Missing HMAC timestamp header".to_string())?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_slack_signature(
secret, ts, body, sig, now_secs,
) {
return Err("Invalid timestamped HMAC signature".to_string());
}
} else {
let sig_header = cfg
.hmac_signature_header
.as_deref()
.unwrap_or("x-hub-signature-256");
let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256=");
let sig = header_value(headers, sig_header)
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed(
secret, body, sig, prefix,
) {
return Err("Invalid HMAC signature".to_string());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use axum::body::Body;
use tower::ServiceExt;
use crate::context::JobContext;
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry};
use super::*;
struct TestWebhookTool;
struct ProtectedWebhookTool;
struct HmacWebhookTool;
/// Tool that declares webhook_capability() but with no auth mechanism configured.
struct MisconfiguredWebhookTool;
#[async_trait]
impl Tool for TestWebhookTool {
fn name(&self) -> &str {
"test_webhook"
}
fn description(&self) -> &str {
"test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
}
#[async_trait]
impl Tool for ProtectedWebhookTool {
fn name(&self) -> &str {
"protected_webhook"
}
fn description(&self) -> &str {
"protected test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability {
secret_name: Some("test_webhook_secret".to_string()),
secret_header: Some("x-webhook-secret".to_string()),
..Default::default()
})
}
}
#[async_trait]
impl Tool for HmacWebhookTool {
fn name(&self) -> &str {
"hmac_webhook"
}
fn description(&self) -> &str {
"hmac test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability {
hmac_secret_name: Some("hmac_secret".to_string()),
hmac_signature_header: Some("x-hub-signature-256".to_string()),
hmac_prefix: Some("sha256=".to_string()),
..Default::default()
})
}
}
#[async_trait]
impl Tool for MisconfiguredWebhookTool {
fn name(&self) -> &str {
"misconfigured_webhook"
}
fn description(&self) -> &str {
"misconfigured test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability::default())
}
}
#[tokio::test]
async fn returns_not_found_for_unknown_tool() {
let tools = Arc::new(ToolRegistry::new());
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/missing")
.body(Body::from("{}"))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn rejects_tool_without_webhook_capability() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(TestWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/test_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_when_required_secret_missing() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ProtectedWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
secrets
.create(
"test",
CreateSecretParams::new("test_webhook_secret", "s3cret"),
)
.await
.expect("secret create");
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/protected_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn accepts_with_valid_hmac_signature() {
use hmac::Mac;
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(HmacWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
secrets
.create(
"test",
CreateSecretParams::new("hmac_secret", "github-secret"),
)
.await
.expect("secret create");
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let payload = br#"{"action":"opened"}"#;
let mut mac =
hmac::Hmac::<sha2::Sha256>::new_from_slice(b"github-secret").expect("hmac key");
mac.update(payload);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/hmac_webhook")
.header("content-type", "application/json")
.header("x-hub-signature-256", sig)
.body(Body::from(payload.to_vec()))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn rejects_empty_webhook_capability_as_misconfigured() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(MisconfiguredWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/misconfigured_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn health_check_returns_ok_for_webhook_capable_tool() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ProtectedWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("GET")
.uri("/webhook/tools/protected_webhook")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn health_check_returns_not_found_for_non_webhook_tool() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(TestWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("GET")
.uri("/webhook/tools/test_webhook")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}
+15 -4
View File
@@ -58,6 +58,7 @@ mod advanced {
let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -95,7 +96,11 @@ mod advanced {
let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt");
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Write 'recovered successfully' to a file for me.")
.await;
@@ -138,7 +143,11 @@ mod advanced {
std::fs::create_dir_all(test_dir).unwrap();
let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message(
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
@@ -232,6 +241,7 @@ mod advanced {
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_max_tool_iterations(3)
.with_auto_approve_tools(true)
.build()
.await;
@@ -242,8 +252,8 @@ mod advanced {
let started = rig.tool_calls_started();
assert!(
started.len() <= 4,
"expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}",
started.len() <= 8,
"expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}",
started.len()
);
assert!(!started.is_empty(), "expected at least 1 tool call, got 0");
@@ -295,6 +305,7 @@ mod advanced {
.with_trace(trace.clone())
.with_routines()
.with_http_exchanges(http_exchanges)
.with_auto_approve_tools(true)
.build()
.await;
+5
View File
@@ -140,6 +140,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -180,6 +181,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -325,6 +327,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -394,6 +397,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -435,6 +439,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
+10 -2
View File
@@ -32,7 +32,11 @@ mod tests {
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("hello").await;
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
@@ -95,7 +99,11 @@ mod tests {
))
.expect("failed to load file_write_read.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Please write a greeting to a file and read it back.")
.await;
+9 -1
View File
@@ -183,7 +183,15 @@ pub fn verify_expects(
// all_tools_succeeded
if expects.all_tools_succeeded == Some(true) {
assert_all_tools_succeeded(completed);
let failed: Vec<&str> = completed
.iter()
.filter(|(_, success)| !*success)
.map(|(name, _)| name.as_str())
.collect();
assert!(
failed.is_empty(),
"[{label}] Expected all tools to succeed, failed={failed:?}, completed={completed:?}, results={results:?}"
);
}
// max_tool_calls
+44 -3
View File
@@ -312,7 +312,23 @@ impl TestRig {
.collect();
let started = self.tool_calls_started();
let completed = self.tool_calls_completed();
let results = self.tool_results();
let mut results = self.tool_results();
for status in self.channel.captured_status_events() {
if let ironclaw::channels::StatusUpdate::ToolCompleted {
name,
success: false,
error,
parameters,
} = status
{
let detail = format!(
"error={}; params={}",
error.unwrap_or_else(|| "unknown".to_string()),
parameters.unwrap_or_else(|| "{}".to_string())
);
results.push((name, detail));
}
}
verify_expects(
&trace.expects,
&all_response_strings,
@@ -339,7 +355,23 @@ impl TestRig {
let response_strings: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
let started = self.tool_calls_started();
let completed = self.tool_calls_completed();
let results = self.tool_results();
let mut results = self.tool_results();
for status in self.channel.captured_status_events() {
if let ironclaw::channels::StatusUpdate::ToolCompleted {
name,
success: false,
error,
parameters,
} = status
{
let detail = format!(
"error={}; params={}",
error.unwrap_or_else(|| "unknown".to_string()),
parameters.unwrap_or_else(|| "{}".to_string())
);
results.push((name, detail));
}
}
verify_expects(
&trace.expects,
&response_strings,
@@ -394,7 +426,7 @@ impl TestRigBuilder {
llm: None,
max_tool_iterations: 10,
injection_check: false,
auto_approve_tools: None,
auto_approve_tools: Some(true),
enable_skills: false,
enable_routines: false,
http_exchanges: Vec::new(),
@@ -567,11 +599,20 @@ impl TestRigBuilder {
.await
.expect("AppBuilder::build_all() failed in test rig");
// AppBuilder may re-resolve config from env/TOML and override test defaults.
// Force test-rig agent flags to the requested deterministic values.
components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true);
components.config.agent.allow_local_tools = true;
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
Arc::new(tokio::sync::RwLock::new(None));
// 6. Register job tools, routine tools, and extra tools.
{
// Ensure filesystem/shell dev tools are always available in the
// test rig, even if upstream builder flags/config disable local tools.
components.tools.register_dev_tools();
components.tools.register_job_tools(
Arc::clone(&components.context_manager),
Some(scheduler_slot.clone()),