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
+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 {