VYPR
High severityNVD Advisory· Published Mar 12, 2026· Updated Mar 12, 2026

ZeptoClaw: Generic webhook channel trusts caller-supplied identity fields; allowlist is checked against untrusted payload data

CVE-2026-32231

Description

ZeptoClaw is a personal AI assistant. Prior to 0.7.6, the generic webhook channel trusts caller-supplied identity fields (sender, chat_id) from the request body and applies authorization checks to those untrusted values. Because authentication is optional and defaults to disabled (auth_token: None), an attacker who can reach POST /webhook can spoof an allowlisted sender and choose arbitrary chat_id values, enabling high-risk message spoofing and potential IDOR-style session/chat routing abuse. This vulnerability is fixed in 0.7.6.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

ZeptoClaw prior to 0.7.6 trusts webhook caller-supplied identity fields; spoofing and IDOR attacks possible.

Vulnerability

Details The vulnerability in ZeptoClaw prior to version 0.7.6 lies in the generic webhook channel, which trusts identity fields (sender, chat_id) supplied by the caller in the request body. The system applies authorization checks against these untrusted values, and authentication is optional, defaulting to disabled (auth_token: None). This means an attacker who can reach the POST /webhook endpoint can spoof an allowlisted sender and choose arbitrary chat_id values, as described in the CVE and security advisory [2][4].

Exploitation

Scenario Attackers can exploit this by sending a crafted HTTP POST request to the webhook endpoint without any authorization header, using a payload that sets sender to a value present in the allow_from list and any desired chat_id. The PoC in the advisory demonstrates this with a simple curl command [4]. The attack vector requires network access to the webhook endpoint; exposure to the internet or untrusted networks makes exploitation trivial.

Impact

Successful exploitation enables high-risk message spoofing, where the attacker can impersonate trusted senders and inject commands or content. Additionally, because chat_id is attacker-controlled, IDOR-style session or chat routing abuse is possible, potentially allowing the attacker to read or write messages in arbitrary conversations [2][4].

Mitigation

The vulnerability is fixed in version 0.7.6. The fix in commit bf004a20 introduces proper authentication enforcement and validates identity fields against the configured allowlist [3]. Users should upgrade immediately or, as a workaround, ensure the webhook endpoint is not exposed to untrusted networks and configure a strong auth_token.

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
zeptoclawcrates.io
< 0.7.60.7.6

Affected products

2

Patches

1
bf004a20d368

fix: harden inbound auth and filesystem boundaries (#324)

https://github.com/qhkm/zeptoclawqhkmMar 11, 2026via ghsa
17 files changed · +1280 102
  • AGENTS.md+4 1 modified
    @@ -21,6 +21,9 @@ Project-level guidance for coding agents working in this repository.
     - Channel dispatch: avoids holding the channels map `RwLock` across async `send()` awaits
     - Channel supervisor: polling (15s) detects dead channels, restarts with 60s cooldown, max 5 restarts
     - Channel panic isolation: Slack/Discord/Webhook/WhatsApp/WhatsApp Web/WhatsApp Cloud/Lark/Email/MQTT/Serial spawned tasks are wrapped with `catch_unwind` and panic logging
    +- Webhook auth hardening: generic webhook supports optional HMAC-SHA256 body signatures plus fixed server-side sender/chat identity by default (`trust_payload_identity` is an explicit legacy escape hatch); WhatsApp Cloud verifies `X-Hub-Signature-256` when `app_secret` is configured
    +- Telegram allowlist hardening: numeric user IDs are the safe default for new setups; legacy username matching remains available only through `channels.telegram.allow_usernames` for compatibility and emits warnings when non-numeric allowlist entries are present
    +- Email allowlist limitation surfaced: `channels.email.allowed_senders` matches the parsed `From` header only and now emits config/runtime warnings so authenticated-mail enforcement is pushed upstream
     - Telegram outbound formatting: sends HTML parse mode with `||spoiler||` → `<tg-spoiler>` conversion
     - Discord outbound delivery: supports reply references and thread-create metadata (`discord_thread_*`) in `OutboundMessage`
     - Cron scheduling hardening: dispatch timeout + exponential error backoff + one-shot delete-after-run only on success
    @@ -30,7 +33,7 @@ Project-level guidance for coding agents working in this repository.
     - Memory injection: per-message query-matched injection via shared LTM on `AgentLoop` (startup static injection removed)
     - Tool execution convergence: agent loop and MCP server both route through `kernel::execute_tool()` (shared safety scan + taint checks + single metrics recording)
     - Tool composition: natural language tool creation with `{{param}}` template interpolation
    -- Lightweight mount validator hardening: `validate_mount_not_blocked` now checks unresolved + canonical host paths and rejects Unix regular-file mounts with multiple hard links
    +- Filesystem hardening: filesystem write/edit tools now create parent directories one component at a time inside the workspace and use secure no-follow writes; mount validation rejects Unix regular-file mounts with multiple hard links in both blocked-path and allowlist flows
     - Gateway startup guard: degrade after N crashes to prevent crash loops
     - Loop guard: SHA256 tool-call repetition detection with warn + circuit-breaker stop
     - Tool execution hardening: per-tool-call timeout + panic capture in both `process_message` and `process_message_streaming` tool `join_all` paths
    
  • Cargo.lock+1 0 modified
    @@ -7129,6 +7129,7 @@ dependencies = [
      "jsonwebtoken",
      "landlock",
      "lettre",
    + "libc",
      "lopdf",
      "mail-parser",
      "mockall",
    
  • Cargo.toml+2 0 modified
    @@ -89,6 +89,8 @@ subtle = "2.5"
     hex = "0.4"
     # HMAC-SHA256 for CSRF token generation/validation
     ring = "0.17"
    +# Platform-specific filesystem flags such as O_NOFOLLOW
    +libc = "0.2"
     
     # =============================================================================
     # ASYNC UTILITIES
    
  • CLAUDE.md+10 9 modified
    @@ -264,12 +264,12 @@ src/
     │   ├── manager.rs  # Channel lifecycle management
     │   ├── model_switch.rs # /model command parsing + model registry + persistence
     │   ├── persona_switch.rs # /persona command parsing + preset registry + LTM persistence
    -│   ├── telegram.rs # Telegram bot channel (HTML parse mode + ||spoiler|| support)
    +│   ├── telegram.rs # Telegram bot channel (HTML parse mode + ||spoiler|| support, numeric-ID allowlist default for new setups)
     │   ├── slack.rs    # Slack outbound channel
     │   ├── discord.rs  # Discord Gateway WebSocket + REST (reply + thread create)
    -│   ├── webhook.rs  # Generic HTTP webhook inbound
    +│   ├── webhook.rs  # Generic HTTP webhook inbound with optional Bearer + HMAC auth and fixed server-side identity
     │   ├── whatsapp_web.rs # WhatsApp Web via wa-rs native (feature: whatsapp-web)
    -│   ├── whatsapp_cloud.rs # WhatsApp Cloud API (official webhook + REST)
    +│   ├── whatsapp_cloud.rs # WhatsApp Cloud API (official signed webhook + REST)
     │   ├── lark.rs     # Lark/Feishu messaging (WS long-connection)
     │   ├── email_channel.rs # Email channel (IMAP IDLE + SMTP)
     │   ├── mqtt.rs    # MQTT channel for IoT device messaging (feature: mqtt)
    @@ -336,7 +336,7 @@ src/
     │   ├── binary_plugin.rs # Binary plugin adapter (JSON-RPC 2.0 stdin/stdout)
     │   ├── shell.rs       # Shell execution with runtime isolation
     │   ├── diff.rs        # Unified diff parser/applier (used by edit_file)
    -│   ├── filesystem.rs  # Read, write, list, edit files (4 tools: read, write, list, edit + diff mode)
    +│   ├── filesystem.rs  # Read, write, list, edit files with secure parent-dir creation and no-follow writes
     │   ├── find.rs        # File discovery by glob pattern (FindTool)
     │   ├── grep.rs        # Codebase search by regex pattern (GrepTool)
     │   ├── web.rs         # Web search (Brave + DuckDuckGo + SearXNG) and fetch with SSRF protection
    @@ -449,12 +449,13 @@ OAuth support with PKCE, CSRF state validation, encrypted token persistence, and
     
     ### Channels (`src/channels/`)
     Message input channels via `Channel` trait:
    -- `TelegramChannel` - Telegram bot integration
    +- `TelegramChannel` - Telegram bot integration with numeric-ID allowlists by default for new setups and legacy username matching behind `allow_usernames`
     - `SlackChannel` - Slack outbound messaging
     - `DiscordChannel` - Discord Gateway WebSocket + REST API messaging (replies + thread creation)
    -- `WebhookChannel` - Generic HTTP POST inbound with optional Bearer auth
    +- `WebhookChannel` - Generic HTTP POST inbound with optional Bearer auth, HMAC-SHA256 body signing, and fixed server-side sender/chat identity by default
     - `WhatsAppWebChannel` - WhatsApp Web via wa-rs native client (QR pairing, feature: whatsapp-web)
    -- `WhatsAppCloudChannel` - WhatsApp Cloud API (webhook inbound + REST outbound, no bridge)
    +- `WhatsAppCloudChannel` - WhatsApp Cloud API (signed webhook inbound + REST outbound, no bridge)
    +- `EmailChannel` - IMAP IDLE + SMTP email channel; sender allowlist is parsed From-header trust only and warns accordingly
     - `MqttChannel` - MQTT messaging for IoT devices over WiFi/network (rumqttc, feature: mqtt)
     - `SerialChannel` - UART serial messaging (line-delimited JSON, feature: hardware)
     - CLI mode via direct agent invocation
    @@ -565,8 +566,8 @@ Panel web dashboard backend:
     
     ### Security (`src/security/`)
     - `shell.rs` - Regex-based command blocklist + optional allowlist (`ShellAllowlistMode`: Off/Warn/Strict); includes `.zeptoclaw/config.json` blocklist to prevent LLM-driven config exfiltration
    -- `path.rs` - Workspace path validation, symlink escape detection
    -- `mount.rs` - Mount allowlist validation, docker binary verification, host-path `..` traversal rejection, lightweight blocked-path checks on unresolved paths plus canonical host paths when the source exists, and Unix hardlink alias rejection for regular-file mounts
    +- `path.rs` - Workspace path validation, symlink escape detection, and secure directory-chain creation for write paths
    +- `mount.rs` - Mount allowlist validation, docker binary verification, host-path `..` traversal rejection, lightweight blocked-path checks on unresolved paths plus canonical host paths when the source exists, and Unix hardlink alias rejection for regular-file mounts in both blocked-path and allowlist validation flows
     - `encryption.rs` - `SecretEncryption`: XChaCha20-Poly1305 AEAD + Argon2id KDF, `ENC[...]` ciphertext format, `resolve_master_key()` for env/file/prompt sources, transparent config decrypt on load
     
     ### Tunnel (`src/tunnel/`)
    
  • src/channels/email_channel.rs+19 0 modified
    @@ -21,6 +21,11 @@
     //!   }
     //! }
     //! ```
    +//!
    +//! `allowed_senders` is matched against the parsed inbound `From` header.
    +//! This is a trust-model limitation of IMAP/header-based ingestion, not a
    +//! cryptographic sender-authentication guarantee. If sender authenticity
    +//! matters, enforce SPF/DKIM/DMARC upstream before messages reach this channel.
     
     #[cfg(feature = "channel-email")]
     use futures::FutureExt;
    @@ -65,6 +70,14 @@ pub struct EmailChannel {
     impl EmailChannel {
         /// Create a new `EmailChannel` from configuration.
         pub fn new(config: EmailConfig, bus: Arc<MessageBus>) -> Self {
    +        #[cfg(feature = "channel-email")]
    +        if config.enabled && !config.allowed_senders.is_empty() {
    +            warn!(
    +                "Email allowed_senders relies on the parsed From header only. \
    +                 Enforce SPF/DKIM/DMARC upstream if sender authenticity matters."
    +            );
    +        }
    +
             let base_config = BaseChannelConfig {
                 name: "email".to_string(),
                 allowlist: config.allowed_senders.clone(),
    @@ -447,6 +460,12 @@ impl Channel for EmailChannel {
                     "Email channel starting (IMAP IDLE on {})",
                     self.config.imap_host
                 );
    +            if !self.config.allowed_senders.is_empty() {
    +                warn!(
    +                    "Email allowed_senders checks parsed From headers only; \
    +                     configure authenticated-mail enforcement upstream if sender authenticity matters."
    +                );
    +            }
                 Ok(())
             }
         }
    
  • src/channels/factory.rs+5 0 modified
    @@ -89,6 +89,11 @@ pub async fn register_configured_channels(
                     port: webhook_config.port,
                     path: webhook_config.path.clone(),
                     auth_token: webhook_config.auth_token.clone(),
    +                signature_secret: webhook_config.signature_secret.clone(),
    +                signature_header: webhook_config.signature_header.clone(),
    +                sender_id: webhook_config.sender_id.clone(),
    +                chat_id: webhook_config.chat_id.clone(),
    +                trust_payload_identity: webhook_config.trust_payload_identity,
                 };
                 let base_config = BaseChannelConfig {
                     name: "webhook".to_string(),
    
  • src/channels/telegram.rs+83 10 modified
    @@ -69,6 +69,8 @@ use super::{BaseChannelConfig, Channel};
     /// silently overwrites earlier ones.
     #[derive(Clone)]
     struct Allowlist(Vec<String>);
    +#[derive(Clone, Copy)]
    +struct AllowUsernames(bool);
     #[derive(Clone)]
     struct DefaultModel(String);
     #[derive(Clone)]
    @@ -117,6 +119,35 @@ fn render_telegram_html(content: &str) -> String {
         out
     }
     
    +fn is_numeric_allowlist_entry(entry: &str) -> bool {
    +    let trimmed = entry.trim();
    +    !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit())
    +}
    +
    +fn allowlist_has_username_entries(allowlist: &[String]) -> bool {
    +    allowlist
    +        .iter()
    +        .any(|entry| !is_numeric_allowlist_entry(entry))
    +}
    +
    +fn telegram_allowlist_allows(
    +    allowlist: &[String],
    +    user_id: &str,
    +    username: &str,
    +    allow_usernames: bool,
    +) -> bool {
    +    allowlist.contains(&user_id.to_string())
    +        || (allow_usernames
    +            && !username.is_empty()
    +            && allowlist.iter().any(|entry| {
    +                let entry_lower = entry.trim().to_lowercase();
    +                let user_lower = username.to_lowercase();
    +                entry_lower == user_lower
    +                    || entry_lower == format!("@{user_lower}")
    +                    || format!("@{entry_lower}") == user_lower
    +            }))
    +}
    +
     /// Telegram channel implementation using teloxide.
     ///
     /// This channel connects to Telegram's Bot API to receive and send messages.
    @@ -193,6 +224,18 @@ impl TelegramChannel {
             configured_models: Vec<(String, String)>,
             memory_enabled: bool,
         ) -> Self {
    +        if allowlist_has_username_entries(&config.allow_from) {
    +            if config.allow_usernames {
    +                warn!(
    +                    "Telegram allow_from contains username entries. Username matching is a legacy compatibility mode and can drift if usernames are reassigned; migrate to numeric user IDs and set channels.telegram.allow_usernames=false when ready."
    +                );
    +            } else {
    +                warn!(
    +                    "Telegram allow_from contains non-numeric entries, but channels.telegram.allow_usernames=false so only numeric user IDs will match."
    +                );
    +            }
    +        }
    +
             let base_config = BaseChannelConfig {
                 name: "telegram".to_string(),
                 allowlist: config.allow_from.clone(),
    @@ -312,6 +355,7 @@ impl Channel for TelegramChannel {
             let token = self.config.token.clone();
             let bus = self.bus.clone();
             let allowlist = Allowlist(self.config.allow_from.clone());
    +        let allow_usernames = AllowUsernames(self.config.allow_usernames);
             let deny_by_default = self.config.deny_by_default;
             let overrides_dep = OverridesDep {
                 model: self.model_overrides.clone(),
    @@ -408,6 +452,7 @@ impl Channel for TelegramChannel {
                              msg: Message,
                              bus: Arc<MessageBus>,
                              Allowlist(allowlist): Allowlist,
    +                         AllowUsernames(allow_usernames): AllowUsernames,
                              deny_by_default: bool,
                              overrides_dep: OverridesDep,
                              DefaultModel(default_model): DefaultModel,
    @@ -427,19 +472,15 @@ impl Channel for TelegramChannel {
                                     .unwrap_or_default();
     
                                 // Check allowlist with deny_by_default support.
    -                            // Accepts both numeric IDs ("123456") and usernames ("alice" or "@alice").
                                 let allowed = if allowlist.is_empty() {
                                     !deny_by_default
                                 } else {
    -                                allowlist.contains(&user_id)
    -                                    || (!username.is_empty()
    -                                        && allowlist.iter().any(|entry| {
    -                                            let entry_lower = entry.to_lowercase();
    -                                            let user_lower = username.to_lowercase();
    -                                            entry_lower == user_lower
    -                                                || entry_lower == format!("@{user_lower}")
    -                                                || format!("@{entry_lower}") == user_lower
    -                                        }))
    +                                telegram_allowlist_allows(
    +                                    &allowlist,
    +                                    &user_id,
    +                                    &username,
    +                                    allow_usernames,
    +                                )
                                 };
                                 if !allowed {
                                     if allowlist.is_empty() {
    @@ -763,6 +804,7 @@ impl Channel for TelegramChannel {
                         .dependencies(dptree::deps![
                             bus,
                             allowlist,
    +                        allow_usernames,
                             deny_by_default,
                             overrides_dep,
                             default_model,
    @@ -1016,6 +1058,37 @@ mod tests {
             assert!(!channel.is_allowed("hacker"));
         }
     
    +    #[test]
    +    fn test_telegram_allowlist_allows_numeric_user_id_without_usernames() {
    +        let allowlist = vec!["123456".to_string()];
    +        assert!(telegram_allowlist_allows(
    +            &allowlist, "123456", "alice", false
    +        ));
    +        assert!(!telegram_allowlist_allows(
    +            &allowlist, "999999", "alice", false
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_telegram_allowlist_rejects_username_when_disabled() {
    +        let allowlist = vec!["alice".to_string(), "@bob".to_string()];
    +        assert!(!telegram_allowlist_allows(
    +            &allowlist, "123456", "alice", false
    +        ));
    +        assert!(!telegram_allowlist_allows(
    +            &allowlist, "123456", "bob", false
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_telegram_allowlist_allows_legacy_username_when_enabled() {
    +        let allowlist = vec!["alice".to_string(), "@bob".to_string()];
    +        assert!(telegram_allowlist_allows(
    +            &allowlist, "123456", "alice", true
    +        ));
    +        assert!(telegram_allowlist_allows(&allowlist, "123456", "bob", true));
    +    }
    +
         #[test]
         fn test_render_telegram_html_escapes_html() {
             let rendered = render_telegram_html("5 < 7 & 9 > 2");
    
  • src/channels/webhook.rs+481 42 modified
    @@ -27,9 +27,7 @@
     //! Authorization: Bearer <optional-token>
     //!
     //! {
    -//!     "message": "Hello, ZeptoClaw!",
    -//!     "sender": "external-service",
    -//!     "chat_id": "webhook-chat-123"
    +//!     "message": "Hello, ZeptoClaw!"
     //! }
     //! ```
     //!
    @@ -50,6 +48,7 @@
     use async_trait::async_trait;
     use futures::FutureExt;
     use serde::Deserialize;
    +use sha2::Digest;
     use std::sync::atomic::{AtomicBool, Ordering};
     use std::sync::Arc;
     use tokio::io::{AsyncReadExt, AsyncWriteExt};
    @@ -79,11 +78,40 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
         result == 0
     }
     
    +fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
    +    let mut k = [0u8; SHA256_BLOCK_SIZE];
    +    if key.len() > SHA256_BLOCK_SIZE {
    +        let hashed = sha2::Sha256::digest(key);
    +        k[..SHA256_OUTPUT_SIZE].copy_from_slice(&hashed);
    +    } else {
    +        k[..key.len()].copy_from_slice(key);
    +    }
    +
    +    let mut k_ipad = [0u8; SHA256_BLOCK_SIZE];
    +    let mut k_opad = [0u8; SHA256_BLOCK_SIZE];
    +    for i in 0..SHA256_BLOCK_SIZE {
    +        k_ipad[i] = k[i] ^ 0x36;
    +        k_opad[i] = k[i] ^ 0x5c;
    +    }
    +
    +    let mut inner = sha2::Sha256::new();
    +    inner.update(k_ipad);
    +    inner.update(message);
    +    let inner_result = inner.finalize();
    +
    +    let mut outer = sha2::Sha256::new();
    +    outer.update(k_opad);
    +    outer.update(inner_result);
    +    hex::encode(outer.finalize())
    +}
    +
     /// Maximum allowed request body size (1 MB).
     const MAX_BODY_SIZE: usize = 1_048_576;
     
     /// Maximum allowed header section size (8 KB).
     const MAX_HEADER_SIZE: usize = 8_192;
    +const SHA256_BLOCK_SIZE: usize = 64;
    +const SHA256_OUTPUT_SIZE: usize = 32;
     
     // --- HTTP response constants ---
     
    @@ -110,6 +138,17 @@ pub struct WebhookChannelConfig {
         /// Optional Bearer token for request authentication.
         /// When set, all requests must include a matching `Authorization: Bearer <token>` header.
         pub auth_token: Option<String>,
    +    /// Optional HMAC secret for request signature verification.
    +    pub signature_secret: Option<String>,
    +    /// Header carrying the HMAC signature when `signature_secret` is configured.
    +    pub signature_header: String,
    +    /// Server-controlled sender ID used when `trust_payload_identity` is disabled.
    +    pub sender_id: Option<String>,
    +    /// Optional server-controlled chat ID used when `trust_payload_identity` is disabled.
    +    /// Falls back to `sender_id` when omitted.
    +    pub chat_id: Option<String>,
    +    /// When true, accept caller-supplied `sender` and `chat_id` from the JSON payload.
    +    pub trust_payload_identity: bool,
     }
     
     impl Default for WebhookChannelConfig {
    @@ -119,6 +158,11 @@ impl Default for WebhookChannelConfig {
                 port: 9876,
                 path: "/webhook".to_string(),
                 auth_token: None,
    +            signature_secret: None,
    +            signature_header: "X-ZeptoClaw-Signature-256".to_string(),
    +            sender_id: None,
    +            chat_id: None,
    +            trust_payload_identity: false,
             }
         }
     }
    @@ -129,9 +173,11 @@ struct WebhookPayload {
         /// The message content.
         message: String,
         /// Identifier of the sender.
    -    sender: String,
    +    #[serde(default)]
    +    sender: Option<String>,
         /// Chat/conversation identifier for session routing.
    -    chat_id: String,
    +    #[serde(default)]
    +    chat_id: Option<String>,
     }
     
     /// Parsed representation of an incoming HTTP request (first line + headers + body).
    @@ -231,6 +277,99 @@ impl WebhookChannel {
             })
         }
     
    +    /// Validate an optional request signature header against the configured secret.
    +    ///
    +    /// The expected format is `sha256=<hex-digest>`, matching the WhatsApp Cloud
    +    /// webhook convention so operators can reuse existing tooling.
    +    fn validate_signature(
    +        headers: &[(String, String)],
    +        body: &str,
    +        header_name: &str,
    +        secret: &Option<String>,
    +    ) -> bool {
    +        let secret = match secret {
    +            Some(secret) => secret,
    +            None => return true,
    +        };
    +
    +        let provided = headers
    +            .iter()
    +            .find(|(name, _)| name.eq_ignore_ascii_case(header_name))
    +            .map(|(_, value)| value.trim());
    +
    +        let provided = match provided {
    +            Some(value) => value,
    +            None => return false,
    +        };
    +
    +        let expected = format!(
    +            "sha256={}",
    +            hmac_sha256_hex(secret.as_bytes(), body.as_bytes())
    +        );
    +        constant_time_eq(provided, &expected)
    +    }
    +
    +    fn validate_runtime_config(config: &WebhookChannelConfig) -> Result<()> {
    +        if config.trust_payload_identity {
    +            return Ok(());
    +        }
    +
    +        let sender_id = config
    +            .sender_id
    +            .as_deref()
    +            .map(str::trim)
    +            .unwrap_or_default();
    +        if sender_id.is_empty() {
    +            return Err(ZeptoError::Config(
    +                "Webhook channel requires sender_id unless trust_payload_identity=true".to_string(),
    +            ));
    +        }
    +
    +        Ok(())
    +    }
    +
    +    fn resolve_identity(
    +        payload: &WebhookPayload,
    +        config: &WebhookChannelConfig,
    +    ) -> Result<(String, String)> {
    +        if config.trust_payload_identity {
    +            let sender = payload.sender.as_deref().map(str::trim).unwrap_or_default();
    +            let chat_id = payload
    +                .chat_id
    +                .as_deref()
    +                .map(str::trim)
    +                .unwrap_or_default();
    +
    +            if sender.is_empty() || chat_id.is_empty() {
    +                return Err(ZeptoError::Channel(
    +                    "message, sender, and chat_id must be non-empty".to_string(),
    +                ));
    +            }
    +
    +            return Ok((sender.to_string(), chat_id.to_string()));
    +        }
    +
    +        let sender = config
    +            .sender_id
    +            .as_deref()
    +            .map(str::trim)
    +            .unwrap_or_default();
    +        if sender.is_empty() {
    +            return Err(ZeptoError::Config(
    +                "Webhook channel requires sender_id unless trust_payload_identity=true".to_string(),
    +            ));
    +        }
    +
    +        let chat_id = config
    +            .chat_id
    +            .as_deref()
    +            .map(str::trim)
    +            .filter(|chat_id| !chat_id.is_empty())
    +            .unwrap_or(sender);
    +
    +        Ok((sender.to_string(), chat_id.to_string()))
    +    }
    +
         /// Parse a raw HTTP request from bytes into structured parts.
         ///
         /// This is intentionally minimal — it only handles what the webhook needs:
    @@ -384,6 +523,16 @@ impl WebhookChannel {
                 return;
             }
     
    +        if !Self::validate_signature(
    +            &request.headers,
    +            &request.body,
    +            &config.signature_header,
    +            &config.signature_secret,
    +        ) {
    +            let _ = stream.write_all(HTTP_401_UNAUTHORIZED.as_bytes()).await;
    +            return;
    +        }
    +
             // Parse JSON body
             let payload: WebhookPayload = match serde_json::from_str(&request.body) {
                 Ok(p) => p,
    @@ -401,12 +550,8 @@ impl WebhookChannel {
                 }
             };
     
    -        // Validate required fields are non-empty
    -        if payload.message.trim().is_empty()
    -            || payload.sender.trim().is_empty()
    -            || payload.chat_id.trim().is_empty()
    -        {
    -            let body = r#"{"error":"message, sender, and chat_id must be non-empty"}"#;
    +        if payload.message.trim().is_empty() {
    +            let body = r#"{"error":"message must be non-empty"}"#;
                 let response = format!(
                     "{}Content-Length: {}\r\n\r\n{}",
                     HTTP_400_BAD_REQUEST,
    @@ -417,23 +562,31 @@ impl WebhookChannel {
                 return;
             }
     
    +        let (sender_id, chat_id) = match Self::resolve_identity(&payload, config) {
    +            Ok(identity) => identity,
    +            Err(e) => {
    +                debug!("Webhook: invalid identity configuration or payload: {}", e);
    +                let body = format!("{{\"error\":\"{}\"}}", e);
    +                let response = format!(
    +                    "{}Content-Length: {}\r\n\r\n{}",
    +                    HTTP_400_BAD_REQUEST,
    +                    body.len(),
    +                    body
    +                );
    +                let _ = stream.write_all(response.as_bytes()).await;
    +                return;
    +            }
    +        };
    +
             // Check allowlist
    -        if !base_config.is_allowed(&payload.sender) {
    -            info!(
    -                "Webhook: sender {} not in allowlist, rejecting",
    -                payload.sender
    -            );
    +        if !base_config.is_allowed(&sender_id) {
    +            info!("Webhook: sender {} not in allowlist, rejecting", sender_id);
                 let _ = stream.write_all(HTTP_401_UNAUTHORIZED.as_bytes()).await;
                 return;
             }
     
             // Build and publish inbound message
    -        let inbound = InboundMessage::new(
    -            "webhook",
    -            payload.sender.trim(),
    -            payload.chat_id.trim(),
    -            payload.message.trim(),
    -        );
    +        let inbound = InboundMessage::new("webhook", &sender_id, &chat_id, payload.message.trim());
     
             if let Err(e) = bus.publish_inbound(inbound).await {
                 error!("Webhook: failed to publish inbound message: {}", e);
    @@ -449,7 +602,7 @@ impl WebhookChannel {
     
             info!(
                 "Webhook: received message from {} in chat {}",
    -            payload.sender, payload.chat_id
    +            sender_id, chat_id
             );
             let _ = stream.write_all(HTTP_200_OK.as_bytes()).await;
         }
    @@ -483,6 +636,11 @@ impl Channel for WebhookChannel {
                 return Ok(());
             }
     
    +        if let Err(e) = Self::validate_runtime_config(&self.config) {
    +            self.running.store(false, Ordering::SeqCst);
    +            return Err(e);
    +        }
    +
             let bind_addr = format!("{}:{}", self.config.bind_address, self.config.port);
     
             let listener = TcpListener::bind(&bind_addr).await.map_err(|e| {
    @@ -626,6 +784,11 @@ mod tests {
             assert_eq!(config.port, 9876);
             assert_eq!(config.path, "/webhook");
             assert!(config.auth_token.is_none());
    +        assert!(config.signature_secret.is_none());
    +        assert_eq!(config.signature_header, "X-ZeptoClaw-Signature-256");
    +        assert!(config.sender_id.is_none());
    +        assert!(config.chat_id.is_none());
    +        assert!(!config.trust_payload_identity);
         }
     
         #[test]
    @@ -635,11 +798,21 @@ mod tests {
                 port: 8080,
                 path: "/api/hook".to_string(),
                 auth_token: Some("secret-token".to_string()),
    +            signature_secret: Some("sig-secret".to_string()),
    +            signature_header: "X-Test-Signature".to_string(),
    +            sender_id: Some("service-a".to_string()),
    +            chat_id: Some("chat-a".to_string()),
    +            trust_payload_identity: false,
             };
             assert_eq!(config.bind_address, "0.0.0.0");
             assert_eq!(config.port, 8080);
             assert_eq!(config.path, "/api/hook");
             assert_eq!(config.auth_token, Some("secret-token".to_string()));
    +        assert_eq!(config.signature_secret, Some("sig-secret".to_string()));
    +        assert_eq!(config.signature_header, "X-Test-Signature");
    +        assert_eq!(config.sender_id, Some("service-a".to_string()));
    +        assert_eq!(config.chat_id, Some("chat-a".to_string()));
    +        assert!(!config.trust_payload_identity);
         }
     
         // -----------------------------------------------------------------------
    @@ -751,6 +924,117 @@ mod tests {
             assert!(!WebhookChannel::validate_auth(&headers, &token));
         }
     
    +    #[test]
    +    fn test_signature_validation_not_required_without_secret() {
    +        let headers = vec![("Content-Type".to_string(), "application/json".to_string())];
    +        assert!(WebhookChannel::validate_signature(
    +            &headers,
    +            r#"{"message":"ok"}"#,
    +            "X-Test-Signature",
    +            &None,
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_signature_validation_valid_signature() {
    +        let secret = Some("topsecret".to_string());
    +        let body = r#"{"message":"hello","sender":"svc","chat_id":"c1"}"#;
    +        let sig = format!("sha256={}", hmac_sha256_hex(b"topsecret", body.as_bytes()));
    +        let headers = vec![("X-Test-Signature".to_string(), sig)];
    +        assert!(WebhookChannel::validate_signature(
    +            &headers,
    +            body,
    +            "X-Test-Signature",
    +            &secret,
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_signature_validation_missing_header_when_required() {
    +        let secret = Some("topsecret".to_string());
    +        let headers = vec![("Content-Type".to_string(), "application/json".to_string())];
    +        assert!(!WebhookChannel::validate_signature(
    +            &headers,
    +            r#"{"message":"ok"}"#,
    +            "X-Test-Signature",
    +            &secret,
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_signature_validation_invalid_signature() {
    +        let secret = Some("topsecret".to_string());
    +        let headers = vec![(
    +            "X-Test-Signature".to_string(),
    +            "sha256=deadbeef".to_string(),
    +        )];
    +        assert!(!WebhookChannel::validate_signature(
    +            &headers,
    +            r#"{"message":"ok"}"#,
    +            "X-Test-Signature",
    +            &secret,
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_validate_runtime_config_requires_sender_id_by_default() {
    +        let config = WebhookChannelConfig::default();
    +        assert!(WebhookChannel::validate_runtime_config(&config).is_err());
    +    }
    +
    +    #[test]
    +    fn test_validate_runtime_config_allows_trusted_payload_identity() {
    +        let config = WebhookChannelConfig {
    +            trust_payload_identity: true,
    +            ..WebhookChannelConfig::default()
    +        };
    +        assert!(WebhookChannel::validate_runtime_config(&config).is_ok());
    +    }
    +
    +    #[test]
    +    fn test_resolve_identity_uses_configured_identity() {
    +        let payload: WebhookPayload =
    +            serde_json::from_str(r#"{"message":"hello","sender":"ignored","chat_id":"ignored"}"#)
    +                .unwrap();
    +        let config = WebhookChannelConfig {
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            ..WebhookChannelConfig::default()
    +        };
    +
    +        let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &config).unwrap();
    +        assert_eq!(sender_id, "fixed-sender");
    +        assert_eq!(chat_id, "fixed-chat");
    +    }
    +
    +    #[test]
    +    fn test_resolve_identity_falls_back_chat_id_to_sender_id() {
    +        let payload: WebhookPayload = serde_json::from_str(r#"{"message":"hello"}"#).unwrap();
    +        let config = WebhookChannelConfig {
    +            sender_id: Some("fixed-sender".to_string()),
    +            ..WebhookChannelConfig::default()
    +        };
    +
    +        let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &config).unwrap();
    +        assert_eq!(sender_id, "fixed-sender");
    +        assert_eq!(chat_id, "fixed-sender");
    +    }
    +
    +    #[test]
    +    fn test_resolve_identity_uses_payload_when_legacy_mode_enabled() {
    +        let payload: WebhookPayload =
    +            serde_json::from_str(r#"{"message":"hello","sender":"svc","chat_id":"chat-1"}"#)
    +                .unwrap();
    +        let config = WebhookChannelConfig {
    +            trust_payload_identity: true,
    +            ..WebhookChannelConfig::default()
    +        };
    +
    +        let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &config).unwrap();
    +        assert_eq!(sender_id, "svc");
    +        assert_eq!(chat_id, "chat-1");
    +    }
    +
         // -----------------------------------------------------------------------
         // 5. HTTP request parsing
         // -----------------------------------------------------------------------
    @@ -793,13 +1077,13 @@ mod tests {
             let json = r#"{"message":"hello","sender":"svc-a","chat_id":"chat-1"}"#;
             let payload: WebhookPayload = serde_json::from_str(json).expect("should parse");
             assert_eq!(payload.message, "hello");
    -        assert_eq!(payload.sender, "svc-a");
    -        assert_eq!(payload.chat_id, "chat-1");
    +        assert_eq!(payload.sender.as_deref(), Some("svc-a"));
    +        assert_eq!(payload.chat_id.as_deref(), Some("chat-1"));
         }
     
         #[test]
         fn test_webhook_payload_missing_fields() {
    -        let json = r#"{"message":"hello"}"#;
    +        let json = r#"{}"#;
             let result: std::result::Result<WebhookPayload, _> = serde_json::from_str(json);
             assert!(result.is_err());
         }
    @@ -926,13 +1210,23 @@ mod tests {
                 port: 3000,
                 path: "/hooks/inbound".to_string(),
                 auth_token: Some("abc".to_string()),
    +            signature_secret: Some("sig".to_string()),
    +            signature_header: "X-Signature".to_string(),
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            trust_payload_identity: false,
             };
             let channel = WebhookChannel::new(config, BaseChannelConfig::new("webhook"), test_bus());
             let cfg = channel.webhook_config();
             assert_eq!(cfg.bind_address, "10.0.0.1");
             assert_eq!(cfg.port, 3000);
             assert_eq!(cfg.path, "/hooks/inbound");
             assert_eq!(cfg.auth_token, Some("abc".to_string()));
    +        assert_eq!(cfg.signature_secret, Some("sig".to_string()));
    +        assert_eq!(cfg.signature_header, "X-Signature");
    +        assert_eq!(cfg.sender_id, Some("fixed-sender".to_string()));
    +        assert_eq!(cfg.chat_id, Some("fixed-chat".to_string()));
    +        assert!(!cfg.trust_payload_identity);
         }
     
         // -----------------------------------------------------------------------
    @@ -949,6 +1243,11 @@ mod tests {
                 port: 0,
                 path: "/webhook".to_string(),
                 auth_token: None,
    +            signature_secret: None,
    +            signature_header: "X-ZeptoClaw-Signature-256".to_string(),
    +            sender_id: Some("svc".to_string()),
    +            chat_id: Some("ch1".to_string()),
    +            trust_payload_identity: false,
             };
     
             // We need to bind ourselves first to discover the actual port, then
    @@ -979,28 +1278,31 @@ mod tests {
             // Parse body
             let payload: WebhookPayload = serde_json::from_str(&req.body).expect("should parse body");
             assert_eq!(payload.message, "integration");
    -        assert_eq!(payload.sender, "svc");
    -        assert_eq!(payload.chat_id, "ch1");
    +        assert_eq!(payload.sender.as_deref(), Some("svc"));
    +        assert_eq!(payload.chat_id.as_deref(), Some("ch1"));
    +
    +        let identity_cfg = WebhookChannelConfig {
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            ..WebhookChannelConfig::default()
    +        };
    +        let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &identity_cfg)
    +            .expect("should resolve identity");
     
             // Verify allowlist check passes
    -        assert!(base.is_allowed(&payload.sender));
    +        assert!(base.is_allowed(&sender_id));
     
             // Publish
    -        let inbound = InboundMessage::new(
    -            "webhook",
    -            &payload.sender,
    -            &payload.chat_id,
    -            &payload.message,
    -        );
    +        let inbound = InboundMessage::new("webhook", &sender_id, &chat_id, &payload.message);
             bus_clone.publish_inbound(inbound).await.unwrap();
     
             // Consume and verify
             let received = bus.consume_inbound().await.expect("should receive message");
             assert_eq!(received.channel, "webhook");
    -        assert_eq!(received.sender_id, "svc");
    -        assert_eq!(received.chat_id, "ch1");
    +        assert_eq!(received.sender_id, "fixed-sender");
    +        assert_eq!(received.chat_id, "fixed-chat");
             assert_eq!(received.content, "integration");
    -        assert_eq!(received.session_key, "webhook:ch1");
    +        assert_eq!(received.session_key, "webhook:fixed-chat");
     
             // Clean up unused duplex streams
             drop(client_stream);
    @@ -1025,6 +1327,11 @@ mod tests {
                 port,
                 path: "/webhook".to_string(),
                 auth_token: None,
    +            signature_secret: None,
    +            signature_header: "X-ZeptoClaw-Signature-256".to_string(),
    +            sender_id: Some("svc".to_string()),
    +            chat_id: Some("ch1".to_string()),
    +            trust_payload_identity: false,
             };
     
             let mut channel =
    @@ -1063,6 +1370,11 @@ mod tests {
                 port,
                 path: "/webhook".to_string(),
                 auth_token: None,
    +            signature_secret: None,
    +            signature_header: "X-ZeptoClaw-Signature-256".to_string(),
    +            sender_id: Some("svc".to_string()),
    +            chat_id: Some("ch1".to_string()),
    +            trust_payload_identity: false,
             };
     
             let mut channel =
    @@ -1096,6 +1408,11 @@ mod tests {
                 port,
                 path: "/webhook".to_string(),
                 auth_token: Some("test-token".to_string()),
    +            signature_secret: None,
    +            signature_header: "X-ZeptoClaw-Signature-256".to_string(),
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            trust_payload_identity: false,
             };
     
             let mut channel =
    @@ -1109,7 +1426,7 @@ mod tests {
                 .await
                 .expect("should connect");
     
    -        let body = r#"{"message":"e2e test","sender":"test-client","chat_id":"e2e-chat"}"#;
    +        let body = r#"{"message":"e2e test"}"#;
             let request = format!(
                 "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer test-token\r\nContent-Length: {}\r\n\r\n{}",
                 body.len(),
    @@ -1139,8 +1456,8 @@ mod tests {
                     .expect("should receive message");
     
             assert_eq!(received.channel, "webhook");
    -        assert_eq!(received.sender_id, "test-client");
    -        assert_eq!(received.chat_id, "e2e-chat");
    +        assert_eq!(received.sender_id, "fixed-sender");
    +        assert_eq!(received.chat_id, "fixed-chat");
             assert_eq!(received.content, "e2e test");
     
             channel.stop().await.unwrap();
    @@ -1157,6 +1474,11 @@ mod tests {
                 port,
                 path: "/webhook".to_string(),
                 auth_token: Some("correct-token".to_string()),
    +            signature_secret: None,
    +            signature_header: "X-ZeptoClaw-Signature-256".to_string(),
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            trust_payload_identity: false,
             };
     
             let mut channel =
    @@ -1169,7 +1491,7 @@ mod tests {
                 .await
                 .expect("should connect");
     
    -        let body = r#"{"message":"test","sender":"s","chat_id":"c"}"#;
    +        let body = r#"{"message":"test"}"#;
             let request = format!(
                 "POST /webhook HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer wrong-token\r\nContent-Length: {}\r\n\r\n{}",
                 body.len(),
    @@ -1192,4 +1514,121 @@ mod tests {
     
             channel.stop().await.unwrap();
         }
    +
    +    #[tokio::test]
    +    async fn test_webhook_end_to_end_signature_required() {
    +        let bus = test_bus();
    +
    +        let temp_listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind");
    +        let port = temp_listener.local_addr().unwrap().port();
    +        drop(temp_listener);
    +
    +        let config = WebhookChannelConfig {
    +            bind_address: "127.0.0.1".to_string(),
    +            port,
    +            path: "/webhook".to_string(),
    +            auth_token: Some("test-token".to_string()),
    +            signature_secret: Some("shared-secret".to_string()),
    +            signature_header: "X-Test-Signature".to_string(),
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            trust_payload_identity: false,
    +        };
    +
    +        let mut channel =
    +            WebhookChannel::new(config, BaseChannelConfig::new("webhook"), Arc::clone(&bus));
    +
    +        channel.start().await.unwrap();
    +        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +
    +        let body = r#"{"message":"signed"}"#;
    +        let signature = format!(
    +            "sha256={}",
    +            hmac_sha256_hex(b"shared-secret", body.as_bytes())
    +        );
    +
    +        let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
    +            .await
    +            .expect("should connect");
    +        let request = format!(
    +            "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer test-token\r\nX-Test-Signature: {}\r\nContent-Length: {}\r\n\r\n{}",
    +            signature,
    +            body.len(),
    +            body
    +        );
    +        stream.write_all(request.as_bytes()).await.unwrap();
    +
    +        let mut response_buf = vec![0u8; 4096];
    +        let n = tokio::time::timeout(
    +            std::time::Duration::from_secs(5),
    +            stream.read(&mut response_buf),
    +        )
    +        .await
    +        .expect("should not timeout")
    +        .expect("should read");
    +
    +        let response = std::str::from_utf8(&response_buf[..n]).expect("valid utf8");
    +        assert!(response.starts_with("HTTP/1.1 200 OK"));
    +
    +        let received =
    +            tokio::time::timeout(std::time::Duration::from_secs(2), bus.consume_inbound())
    +                .await
    +                .expect("should not timeout")
    +                .expect("should receive message");
    +        assert_eq!(received.content, "signed");
    +        assert_eq!(received.sender_id, "fixed-sender");
    +        assert_eq!(received.chat_id, "fixed-chat");
    +
    +        channel.stop().await.unwrap();
    +    }
    +
    +    #[tokio::test]
    +    async fn test_webhook_end_to_end_missing_required_signature() {
    +        let temp_listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind");
    +        let port = temp_listener.local_addr().unwrap().port();
    +        drop(temp_listener);
    +
    +        let config = WebhookChannelConfig {
    +            bind_address: "127.0.0.1".to_string(),
    +            port,
    +            path: "/webhook".to_string(),
    +            auth_token: Some("test-token".to_string()),
    +            signature_secret: Some("shared-secret".to_string()),
    +            signature_header: "X-Test-Signature".to_string(),
    +            sender_id: Some("fixed-sender".to_string()),
    +            chat_id: Some("fixed-chat".to_string()),
    +            trust_payload_identity: false,
    +        };
    +
    +        let mut channel =
    +            WebhookChannel::new(config, BaseChannelConfig::new("webhook"), test_bus());
    +
    +        channel.start().await.unwrap();
    +        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +
    +        let body = r#"{"message":"unsigned"}"#;
    +        let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
    +            .await
    +            .expect("should connect");
    +        let request = format!(
    +            "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer test-token\r\nContent-Length: {}\r\n\r\n{}",
    +            body.len(),
    +            body
    +        );
    +        stream.write_all(request.as_bytes()).await.unwrap();
    +
    +        let mut response_buf = vec![0u8; 4096];
    +        let n = tokio::time::timeout(
    +            std::time::Duration::from_secs(5),
    +            stream.read(&mut response_buf),
    +        )
    +        .await
    +        .expect("should not timeout")
    +        .expect("should read");
    +
    +        let response = std::str::from_utf8(&response_buf[..n]).expect("valid utf8");
    +        assert!(response.starts_with("HTTP/1.1 401"));
    +
    +        channel.stop().await.unwrap();
    +    }
     }
    
  • src/channels/whatsapp_cloud.rs+208 1 modified
    @@ -17,6 +17,7 @@ use futures::FutureExt;
     use reqwest::Client;
     use serde::Deserialize;
     use serde_json::{json, Value};
    +use sha2::Digest;
     use std::sync::atomic::{AtomicBool, Ordering};
     use std::sync::Arc;
     use tokio::io::{AsyncReadExt, AsyncWriteExt};
    @@ -39,6 +40,8 @@ const MAX_HEADER_SIZE: usize = 8_192;
     
     /// WhatsApp text message character limit.
     const MAX_MESSAGE_LENGTH: usize = 4096;
    +const SHA256_BLOCK_SIZE: usize = 64;
    +const SHA256_OUTPUT_SIZE: usize = 32;
     
     // --- HTTP response constants ---
     
    @@ -159,6 +162,47 @@ struct ParsedHttpRequest {
     
     // --- Helper functions ---
     
    +fn constant_time_eq(a: &str, b: &str) -> bool {
    +    let a = a.as_bytes();
    +    let b = b.as_bytes();
    +    if a.len() != b.len() {
    +        return false;
    +    }
    +
    +    let mut result = 0u8;
    +    for (x, y) in a.iter().zip(b.iter()) {
    +        result |= x ^ y;
    +    }
    +    result == 0
    +}
    +
    +fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
    +    let mut k = [0u8; SHA256_BLOCK_SIZE];
    +    if key.len() > SHA256_BLOCK_SIZE {
    +        let hashed = sha2::Sha256::digest(key);
    +        k[..SHA256_OUTPUT_SIZE].copy_from_slice(&hashed);
    +    } else {
    +        k[..key.len()].copy_from_slice(key);
    +    }
    +
    +    let mut k_ipad = [0u8; SHA256_BLOCK_SIZE];
    +    let mut k_opad = [0u8; SHA256_BLOCK_SIZE];
    +    for i in 0..SHA256_BLOCK_SIZE {
    +        k_ipad[i] = k[i] ^ 0x36;
    +        k_opad[i] = k[i] ^ 0x5c;
    +    }
    +
    +    let mut inner = sha2::Sha256::new();
    +    inner.update(k_ipad);
    +    inner.update(message);
    +    let inner_result = inner.finalize();
    +
    +    let mut outer = sha2::Sha256::new();
    +    outer.update(k_opad);
    +    outer.update(inner_result);
    +    hex::encode(outer.finalize())
    +}
    +
     /// Parse a raw HTTP request into structured parts.
     fn parse_http_request(raw: &[u8]) -> Result<ParsedHttpRequest> {
         let raw_str = std::str::from_utf8(raw)
    @@ -493,6 +537,33 @@ impl WhatsAppCloudChannel {
             Some(challenge.to_string())
         }
     
    +    fn validate_signature(
    +        headers: &[(String, String)],
    +        body: &str,
    +        app_secret: &Option<String>,
    +    ) -> bool {
    +        let app_secret = match app_secret {
    +            Some(secret) => secret,
    +            None => return true,
    +        };
    +
    +        let provided = headers
    +            .iter()
    +            .find(|(name, _)| name.eq_ignore_ascii_case("x-hub-signature-256"))
    +            .map(|(_, value)| value.trim());
    +
    +        let provided = match provided {
    +            Some(value) => value,
    +            None => return false,
    +        };
    +
    +        let expected = format!(
    +            "sha256={}",
    +            hmac_sha256_hex(app_secret.as_bytes(), body.as_bytes())
    +        );
    +        constant_time_eq(provided, &expected)
    +    }
    +
         /// Handle a single TCP connection.
         async fn handle_connection(
             mut stream: tokio::net::TcpStream,
    @@ -582,7 +653,13 @@ impl WhatsAppCloudChannel {
                     }
                 }
                 "POST" => {
    -                // Always respond 200 immediately (Meta requirement)
    +                if !Self::validate_signature(&request.headers, &request.body, &config.app_secret) {
    +                    warn!("WhatsApp Cloud: invalid or missing X-Hub-Signature-256");
    +                    let _ = stream.write_all(HTTP_403_FORBIDDEN.as_bytes()).await;
    +                    return;
    +                }
    +
    +                // Respond 200 only after the webhook authenticity check passes.
                     let _ = stream.write_all(HTTP_200_OK.as_bytes()).await;
     
                     // Parse notification
    @@ -845,6 +922,7 @@ mod tests {
                 phone_number_id: "123456".to_string(),
                 access_token: "test-token".to_string(),
                 webhook_verify_token: "verify-secret".to_string(),
    +            app_secret: None,
                 bind_address: "127.0.0.1".to_string(),
                 port: 0,
                 path: "/whatsapp".to_string(),
    @@ -909,6 +987,41 @@ mod tests {
             assert!(result.is_none());
         }
     
    +    #[test]
    +    fn test_signature_validation_not_required_without_app_secret() {
    +        let headers = vec![("Content-Type".to_string(), "application/json".to_string())];
    +        assert!(WhatsAppCloudChannel::validate_signature(
    +            &headers,
    +            sample_webhook_json(),
    +            &None,
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_signature_validation_valid_signature() {
    +        let secret = Some("meta-app-secret".to_string());
    +        let body = sample_webhook_json();
    +        let signature = format!(
    +            "sha256={}",
    +            hmac_sha256_hex(b"meta-app-secret", body.as_bytes())
    +        );
    +        let headers = vec![("X-Hub-Signature-256".to_string(), signature)];
    +        assert!(WhatsAppCloudChannel::validate_signature(
    +            &headers, body, &secret
    +        ));
    +    }
    +
    +    #[test]
    +    fn test_signature_validation_missing_header_when_required() {
    +        let secret = Some("meta-app-secret".to_string());
    +        let headers = vec![("Content-Type".to_string(), "application/json".to_string())];
    +        assert!(!WhatsAppCloudChannel::validate_signature(
    +            &headers,
    +            sample_webhook_json(),
    +            &secret,
    +        ));
    +    }
    +
         // -----------------------------------------------------------------------
         // 3. Inbound message parsing
         // -----------------------------------------------------------------------
    @@ -1238,6 +1351,100 @@ mod tests {
             channel.stop().await.unwrap();
         }
     
    +    #[tokio::test]
    +    async fn test_end_to_end_inbound_message_with_valid_signature() {
    +        let bus = test_bus();
    +        let temp_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    +        let port = temp_listener.local_addr().unwrap().port();
    +        drop(temp_listener);
    +
    +        let mut config = test_config();
    +        config.port = port;
    +        config.allow_from = vec![];
    +        config.app_secret = Some("meta-app-secret".to_string());
    +        let mut channel = WhatsAppCloudChannel::new(config, Arc::clone(&bus), None);
    +        channel.start().await.unwrap();
    +        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +
    +        let body = sample_webhook_json();
    +        let signature = format!(
    +            "sha256={}",
    +            hmac_sha256_hex(b"meta-app-secret", body.as_bytes())
    +        );
    +        let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
    +            .await
    +            .unwrap();
    +        let request = format!(
    +            "POST /whatsapp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nX-Hub-Signature-256: {}\r\nContent-Length: {}\r\n\r\n{}",
    +            signature,
    +            body.len(),
    +            body
    +        );
    +        stream.write_all(request.as_bytes()).await.unwrap();
    +
    +        let mut buf = vec![0u8; 4096];
    +        let n = tokio::time::timeout(std::time::Duration::from_secs(5), stream.read(&mut buf))
    +            .await
    +            .unwrap()
    +            .unwrap();
    +
    +        let response = std::str::from_utf8(&buf[..n]).unwrap();
    +        assert!(response.starts_with("HTTP/1.1 200 OK"));
    +
    +        let received =
    +            tokio::time::timeout(std::time::Duration::from_secs(2), bus.consume_inbound())
    +                .await
    +                .unwrap()
    +                .unwrap();
    +        assert_eq!(received.channel, "whatsapp_cloud");
    +        assert_eq!(received.sender_id, "60123456789");
    +
    +        channel.stop().await.unwrap();
    +    }
    +
    +    #[tokio::test]
    +    async fn test_end_to_end_inbound_message_missing_required_signature() {
    +        let bus = test_bus();
    +        let temp_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    +        let port = temp_listener.local_addr().unwrap().port();
    +        drop(temp_listener);
    +
    +        let mut config = test_config();
    +        config.port = port;
    +        config.allow_from = vec![];
    +        config.app_secret = Some("meta-app-secret".to_string());
    +        let mut channel = WhatsAppCloudChannel::new(config, Arc::clone(&bus), None);
    +        channel.start().await.unwrap();
    +        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +
    +        let body = sample_webhook_json();
    +        let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
    +            .await
    +            .unwrap();
    +        let request = format!(
    +            "POST /whatsapp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
    +            body.len(),
    +            body
    +        );
    +        stream.write_all(request.as_bytes()).await.unwrap();
    +
    +        let mut buf = vec![0u8; 4096];
    +        let n = tokio::time::timeout(std::time::Duration::from_secs(5), stream.read(&mut buf))
    +            .await
    +            .unwrap()
    +            .unwrap();
    +
    +        let response = std::str::from_utf8(&buf[..n]).unwrap();
    +        assert!(response.starts_with("HTTP/1.1 403 Forbidden"));
    +        assert!(
    +            tokio::time::timeout(std::time::Duration::from_millis(250), bus.consume_inbound())
    +                .await
    +                .is_err()
    +        );
    +
    +        channel.stop().await.unwrap();
    +    }
    +
         // -----------------------------------------------------------------------
         // 11. Audio content parsing
         // -----------------------------------------------------------------------
    
  • src/cli/channel.rs+41 1 modified
    @@ -225,16 +225,18 @@ fn setup_telegram(config: &mut Config) -> Result<()> {
         tg.token = token;
         tg.enabled = true;
     
    -    print!("Allowlist user IDs/usernames (comma-separated, or Enter for all): ");
    +    print!("Allowlist numeric Telegram user IDs (comma-separated, or Enter for all): ");
         io::stdout().flush()?;
         let allowlist = read_line()?;
         tg.allow_from = allowlist
             .split(',')
             .map(|s| s.trim().to_string())
             .filter(|s| !s.is_empty())
             .collect();
    +    tg.allow_usernames = false;
     
         println!("  Telegram bot configured.");
    +    println!("  Numeric user IDs are recommended; username allowlists stay disabled.");
         println!("  Run 'zeptoclaw gateway' to start the bot.");
         Ok(())
     }
    @@ -346,11 +348,42 @@ fn setup_webhook(config: &mut Config) -> Result<()> {
             wh.auth_token = Some(auth);
         }
     
    +    print!("HMAC signature secret (or Enter to disable body signing): ");
    +    io::stdout().flush()?;
    +    let signature_secret = read_secret()?;
    +    if !signature_secret.is_empty() {
    +        wh.signature_secret = Some(signature_secret);
    +
    +        print!("Signature header [{}]: ", wh.signature_header);
    +        io::stdout().flush()?;
    +        let signature_header = read_line()?;
    +        if !signature_header.is_empty() {
    +            wh.signature_header = signature_header;
    +        }
    +    }
    +
    +    print!("Fixed sender ID (recommended, Enter to configure later): ");
    +    io::stdout().flush()?;
    +    let sender_id = read_line()?;
    +    if !sender_id.is_empty() {
    +        wh.sender_id = Some(sender_id);
    +
    +        print!("Fixed chat ID (Enter to reuse sender ID): ");
    +        io::stdout().flush()?;
    +        let chat_id = read_line()?;
    +        if !chat_id.is_empty() {
    +            wh.chat_id = Some(chat_id);
    +        }
    +    }
    +
         wh.enabled = true;
         println!(
             "  Webhook configured at {}:{}{}",
             wh.bind_address, wh.port, wh.path
         );
    +    if wh.sender_id.is_none() && !wh.trust_payload_identity {
    +        println!("  Note: set channels.webhook.sender_id before starting, or enable trust_payload_identity manually for legacy payload-driven identity.");
    +    }
         println!("  Run 'zeptoclaw gateway' to start listening.");
         Ok(())
     }
    @@ -383,13 +416,20 @@ fn setup_whatsapp_cloud(config: &mut Config) -> Result<()> {
         io::stdout().flush()?;
         let verify_token = read_secret()?;
     
    +    print!("Enter Meta app secret for X-Hub-Signature-256 verification (or Enter to skip): ");
    +    io::stdout().flush()?;
    +    let app_secret = read_secret()?;
    +
         let wc = config
             .channels
             .whatsapp_cloud
             .get_or_insert_with(Default::default);
         wc.phone_number_id = phone_id;
         wc.access_token = token;
         wc.webhook_verify_token = verify_token;
    +    if !app_secret.is_empty() {
    +        wc.app_secret = Some(app_secret);
    +    }
         wc.enabled = true;
     
         println!("  WhatsApp Cloud API configured.");
    
  • src/config/mod.rs+18 0 modified
    @@ -1666,12 +1666,30 @@ mod tests {
             assert!(telegram.enabled);
             assert_eq!(telegram.token, "bot123:ABC");
             assert_eq!(telegram.allow_from, vec!["user1", "user2"]);
    +        assert!(telegram.allow_usernames);
     
             let discord = config.channels.discord.unwrap();
             assert!(!discord.enabled);
             assert_eq!(discord.token, "discord-token");
         }
     
    +    #[test]
    +    fn test_telegram_channel_config_can_disable_username_matching() {
    +        let json = r#"{
    +            "channels": {
    +                "telegram": {
    +                    "enabled": true,
    +                    "token": "bot123:ABC",
    +                    "allow_from": ["123456789"],
    +                    "allow_usernames": false
    +                }
    +            }
    +        }"#;
    +        let config: Config = serde_json::from_str(json).unwrap();
    +        let telegram = config.channels.telegram.unwrap();
    +        assert!(!telegram.allow_usernames);
    +    }
    +
         #[test]
         fn test_provider_configs() {
             let json = r#"{
    
  • src/config/types.rs+62 2 modified
    @@ -885,6 +885,22 @@ pub struct WebhookConfig {
         /// Optional Bearer token for request authentication
         #[serde(default)]
         pub auth_token: Option<String>,
    +    /// Optional HMAC secret for request signature verification.
    +    #[serde(default)]
    +    pub signature_secret: Option<String>,
    +    /// Header carrying the request HMAC signature when `signature_secret` is set.
    +    #[serde(default = "default_webhook_signature_header")]
    +    pub signature_header: String,
    +    /// Server-controlled sender ID used when `trust_payload_identity` is disabled.
    +    #[serde(default)]
    +    pub sender_id: Option<String>,
    +    /// Optional server-controlled chat ID used when `trust_payload_identity` is disabled.
    +    /// Falls back to `sender_id` when omitted.
    +    #[serde(default)]
    +    pub chat_id: Option<String>,
    +    /// When true, accept caller-supplied `sender` and `chat_id` from webhook JSON.
    +    #[serde(default)]
    +    pub trust_payload_identity: bool,
         /// Allowlist of sender IDs (empty = allow all unless `deny_by_default` is set)
         #[serde(default)]
         pub allow_from: Vec<String>,
    @@ -905,6 +921,10 @@ fn default_webhook_path() -> String {
         "/webhook".to_string()
     }
     
    +fn default_webhook_signature_header() -> String {
    +    "X-ZeptoClaw-Signature-256".to_string()
    +}
    +
     impl Default for WebhookConfig {
         fn default() -> Self {
             Self {
    @@ -913,26 +933,55 @@ impl Default for WebhookConfig {
                 port: default_webhook_port(),
                 path: default_webhook_path(),
                 auth_token: None,
    +            signature_secret: None,
    +            signature_header: default_webhook_signature_header(),
    +            sender_id: None,
    +            chat_id: None,
    +            trust_payload_identity: false,
                 allow_from: Vec::new(),
                 deny_by_default: false,
             }
         }
     }
     
    +fn default_telegram_allow_usernames() -> bool {
    +    true
    +}
    +
     /// Telegram channel configuration
    -#[derive(Debug, Clone, Serialize, Deserialize, Default)]
    +#[derive(Debug, Clone, Serialize, Deserialize)]
    +#[serde(default)]
     pub struct TelegramConfig {
         /// Whether the channel is enabled
         #[serde(default)]
         pub enabled: bool,
         /// Bot token from BotFather
         pub token: String,
    -    /// Allowlist of user IDs/usernames (empty = allow all unless `deny_by_default` is set)
    +    /// Allowlist of numeric user IDs (empty = allow all unless `deny_by_default` is set).
    +    ///
    +    /// Legacy username entries are only honored when `allow_usernames` is true.
         #[serde(default)]
         pub allow_from: Vec<String>,
         /// When true, empty `allow_from` rejects all senders (strict mode).
         #[serde(default)]
         pub deny_by_default: bool,
    +    /// Legacy compatibility toggle for username-based allowlist entries.
    +    ///
    +    /// New configs should keep this disabled and use numeric Telegram user IDs only.
    +    #[serde(default = "default_telegram_allow_usernames")]
    +    pub allow_usernames: bool,
    +}
    +
    +impl Default for TelegramConfig {
    +    fn default() -> Self {
    +        Self {
    +            enabled: false,
    +            token: String::new(),
    +            allow_from: Vec::new(),
    +            deny_by_default: false,
    +            allow_usernames: default_telegram_allow_usernames(),
    +        }
    +    }
     }
     
     /// Discord channel configuration
    @@ -987,6 +1036,9 @@ pub struct WhatsAppCloudConfig {
         /// Webhook verify token (you choose this secret, must match Meta dashboard).
         #[serde(default)]
         pub webhook_verify_token: String,
    +    /// Optional Meta app secret used to verify `X-Hub-Signature-256` callback signatures.
    +    #[serde(default)]
    +    pub app_secret: Option<String>,
         /// Address to bind the webhook HTTP server to.
         #[serde(default = "default_whatsapp_cloud_bind")]
         pub bind_address: String,
    @@ -1023,6 +1075,7 @@ impl Default for WhatsAppCloudConfig {
                 phone_number_id: String::new(),
                 access_token: String::new(),
                 webhook_verify_token: String::new(),
    +            app_secret: None,
                 bind_address: default_whatsapp_cloud_bind(),
                 port: default_whatsapp_cloud_port(),
                 path: default_whatsapp_cloud_path(),
    @@ -2483,6 +2536,7 @@ mod tests {
             assert!(config.phone_number_id.is_empty());
             assert!(config.access_token.is_empty());
             assert!(config.webhook_verify_token.is_empty());
    +        assert!(config.app_secret.is_none());
             assert_eq!(config.bind_address, "127.0.0.1");
             assert_eq!(config.port, 9877);
             assert_eq!(config.path, "/whatsapp");
    @@ -2497,6 +2551,7 @@ mod tests {
                 "phone_number_id": "123456",
                 "access_token": "EAAx...",
                 "webhook_verify_token": "my-verify-secret",
    +            "app_secret": "meta-secret",
                 "port": 8443,
                 "allow_from": ["60123456789"]
             }"#;
    @@ -2505,6 +2560,7 @@ mod tests {
             assert_eq!(config.phone_number_id, "123456");
             assert_eq!(config.access_token, "EAAx...");
             assert_eq!(config.webhook_verify_token, "my-verify-secret");
    +        assert_eq!(config.app_secret.as_deref(), Some("meta-secret"));
             assert_eq!(config.port, 8443);
             assert_eq!(config.allow_from, vec!["60123456789"]);
         }
    @@ -2863,6 +2919,10 @@ pub struct EmailConfig {
         #[serde(default)]
         pub display_name: Option<String>,
         /// Allowlist of sender email addresses or domains.
    +    ///
    +    /// These entries are matched against the parsed inbound `From` header.
    +    /// Use upstream authenticated-mail enforcement (SPF/DKIM/DMARC) if sender
    +    /// authenticity matters.
         #[serde(default)]
         pub allowed_senders: Vec<String>,
         /// When `true` and `allowed_senders` is empty, all senders are denied.
    
  • src/config/validate.rs+108 0 modified
    @@ -259,6 +259,53 @@ pub fn validate_config(raw: &Value) -> Vec<Diagnostic> {
                             message: "Empty \u{2014} anyone can message the bot".to_string(),
                         });
                     }
    +
    +                if name == "email"
    +                    && enabled
    +                    && channel_obj
    +                        .get("allowed_senders")
    +                        .and_then(|v| v.as_array())
    +                        .is_some_and(|senders| !senders.is_empty())
    +                {
    +                    diagnostics.push(Diagnostic {
    +                        level: DiagnosticLevel::Warn,
    +                        path: "channels.email.allowed_senders".to_string(),
    +                        message: "Email sender allowlists trust the parsed From header only; enforce SPF/DKIM/DMARC upstream if sender authenticity matters".to_string(),
    +                    });
    +                }
    +
    +                if name == "telegram" {
    +                    let allow_usernames = channel_obj
    +                        .get("allow_usernames")
    +                        .and_then(|v| v.as_bool())
    +                        .unwrap_or(true);
    +                    let has_username_entries = channel_obj
    +                        .get("allow_from")
    +                        .and_then(|v| v.as_array())
    +                        .map(|entries| {
    +                            entries
    +                                .iter()
    +                                .filter_map(|entry| entry.as_str())
    +                                .any(|entry| {
    +                                    let trimmed = entry.trim();
    +                                    trimmed.is_empty()
    +                                        || !trimmed.bytes().all(|b| b.is_ascii_digit())
    +                                })
    +                        })
    +                        .unwrap_or(false);
    +
    +                    if has_username_entries {
    +                        diagnostics.push(Diagnostic {
    +                            level: DiagnosticLevel::Warn,
    +                            path: "channels.telegram.allow_from".to_string(),
    +                            message: if allow_usernames {
    +                                "Telegram username allowlist entries are legacy and can be reassigned; prefer numeric user IDs and set allow_usernames=false after migration".to_string()
    +                            } else {
    +                                "Telegram allow_from contains non-numeric entries, but allow_usernames=false so they will never match".to_string()
    +                            },
    +                        });
    +                    }
    +                }
                 }
             }
         }
    @@ -527,6 +574,67 @@ mod tests {
             }));
         }
     
    +    #[test]
    +    fn test_validate_email_allowed_senders_warns_header_only_trust() {
    +        let raw = json!({
    +            "channels": {
    +                "email": {
    +                    "enabled": true,
    +                    "imap_host": "imap.example.com",
    +                    "smtp_host": "smtp.example.com",
    +                    "username": "bot@example.com",
    +                    "password": "secret",
    +                    "allowed_senders": ["@example.com"]
    +                }
    +            }
    +        });
    +        let diags = validate_config(&raw);
    +        assert!(diags.iter().any(|d| {
    +            d.level == DiagnosticLevel::Warn
    +                && d.path == "channels.email.allowed_senders"
    +                && d.message.contains("From header")
    +        }));
    +    }
    +
    +    #[test]
    +    fn test_validate_telegram_username_allowlist_warns() {
    +        let raw = json!({
    +            "channels": {
    +                "telegram": {
    +                    "enabled": true,
    +                    "token": "abc",
    +                    "allow_from": ["alice"]
    +                }
    +            }
    +        });
    +        let diags = validate_config(&raw);
    +        assert!(diags.iter().any(|d| {
    +            d.level == DiagnosticLevel::Warn
    +                && d.path == "channels.telegram.allow_from"
    +                && d.message.contains("legacy")
    +        }));
    +    }
    +
    +    #[test]
    +    fn test_validate_telegram_username_allowlist_disabled_warns_non_matching() {
    +        let raw = json!({
    +            "channels": {
    +                "telegram": {
    +                    "enabled": true,
    +                    "token": "abc",
    +                    "allow_from": ["alice"],
    +                    "allow_usernames": false
    +                }
    +            }
    +        });
    +        let diags = validate_config(&raw);
    +        assert!(diags.iter().any(|d| {
    +            d.level == DiagnosticLevel::Warn
    +                && d.path == "channels.telegram.allow_from"
    +                && d.message.contains("never match")
    +        }));
    +    }
    +
         #[test]
         fn test_validate_not_an_object() {
             let raw = json!("not an object");
    
  • src/security/mod.rs+4 1 modified
    @@ -14,5 +14,8 @@ pub use agent_mode::{AgentMode, AgentModeConfig, CategoryPermission, ModePolicy}
     pub use encryption::{is_secret_field, resolve_master_key, SecretEncryption};
     pub use mount::{validate_extra_mounts, validate_mount_not_blocked, DEFAULT_BLOCKED_PATTERNS};
     pub use pairing::{DeviceInfo, PairedDevice, PairingManager};
    -pub use path::{check_hardlink_write, revalidate_path, validate_path_in_workspace, SafePath};
    +pub use path::{
    +    check_hardlink_write, ensure_directory_chain_secure, revalidate_path,
    +    validate_path_in_workspace, SafePath,
    +};
     pub use shell::{ShellAllowlistMode, ShellSecurityConfig};
    
  • src/security/mount.rs+19 0 modified
    @@ -214,6 +214,8 @@ pub fn validate_extra_mounts(mounts: &[String], allowlist_path: &str) -> Result<
                 )));
             }
     
    +        validate_no_hardlink_alias(&host_path, mount)?;
    +
             let allowed_root = allowlist
                 .allowed_roots
                 .iter()
    @@ -390,6 +392,23 @@ mod tests {
             assert!(validated[0].ends_with(":ro"));
         }
     
    +    #[cfg(unix)]
    +    #[test]
    +    fn test_validate_mount_rejects_regular_file_with_multiple_hardlinks() {
    +        let temp = tempdir().unwrap();
    +        let source = temp.path().join("source.txt");
    +        let alias = temp.path().join("alias.txt");
    +        std::fs::write(&source, "secret").unwrap();
    +        std::fs::hard_link(&source, &alias).unwrap();
    +
    +        let allowlist = temp.path().join("allowlist.json");
    +        write_allowlist(&allowlist, temp.path(), true);
    +
    +        let mounts = vec![format!("{}:/workspace/data", alias.display())];
    +        let err = validate_extra_mounts(&mounts, allowlist.to_str().unwrap()).unwrap_err();
    +        assert!(err.to_string().contains("hard links"));
    +    }
    +
         #[test]
         fn test_validate_mount_outside_allowed_root_fails() {
             let temp = tempdir().unwrap();
    
  • src/security/path.rs+126 0 modified
    @@ -285,6 +285,106 @@ pub fn revalidate_path(path: &Path, workspace: &str) -> Result<()> {
         Ok(())
     }
     
    +/// Securely ensure a directory chain exists within the workspace.
    +///
    +/// This creates missing directories one component at a time, re-checking the
    +/// workspace boundary and rejecting symlinked or non-directory ancestors.
    +pub fn ensure_directory_chain_secure(path: &Path, workspace: &str) -> Result<()> {
    +    let workspace_path = Path::new(workspace);
    +    let canonical_workspace = workspace_path
    +        .canonicalize()
    +        .unwrap_or_else(|_| normalize_path(workspace_path));
    +    let normalized_path = normalize_path(path);
    +
    +    if !normalized_path.starts_with(&canonical_workspace) {
    +        return Err(ZeptoError::SecurityViolation(format!(
    +            "Directory path escapes workspace: '{}' is not within '{}'",
    +            normalized_path.display(),
    +            canonical_workspace.display()
    +        )));
    +    }
    +
    +    let relative = normalized_path
    +        .strip_prefix(&canonical_workspace)
    +        .map_err(|_| {
    +            ZeptoError::SecurityViolation(format!(
    +                "Directory path escapes workspace: '{}' is not within '{}'",
    +                normalized_path.display(),
    +                canonical_workspace.display()
    +            ))
    +        })?;
    +
    +    let mut current = canonical_workspace.clone();
    +    for component in relative.components() {
    +        current.push(component);
    +
    +        check_symlink_escape(&current, &canonical_workspace)?;
    +
    +        match std::fs::symlink_metadata(&current) {
    +            Ok(meta) => {
    +                if meta.file_type().is_symlink() {
    +                    return Err(ZeptoError::SecurityViolation(format!(
    +                        "Symlink escape detected while creating directory '{}'",
    +                        current.display()
    +                    )));
    +                }
    +                if !meta.is_dir() {
    +                    return Err(ZeptoError::SecurityViolation(format!(
    +                        "Cannot create directory '{}': existing path is not a directory",
    +                        current.display()
    +                    )));
    +                }
    +            }
    +            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
    +                match std::fs::create_dir(&current) {
    +                    Ok(()) => {}
    +                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
    +                    Err(e) => {
    +                        return Err(ZeptoError::Tool(format!(
    +                            "Failed to create directory '{}': {}",
    +                            current.display(),
    +                            e
    +                        )));
    +                    }
    +                }
    +
    +                let meta = std::fs::symlink_metadata(&current).map_err(|e| {
    +                    ZeptoError::Tool(format!(
    +                        "Failed to inspect directory '{}' after creation: {}",
    +                        current.display(),
    +                        e
    +                    ))
    +                })?;
    +                if meta.file_type().is_symlink() || !meta.is_dir() {
    +                    return Err(ZeptoError::SecurityViolation(format!(
    +                        "Directory '{}' became unsafe during creation",
    +                        current.display()
    +                    )));
    +                }
    +            }
    +            Err(e) => {
    +                return Err(ZeptoError::Tool(format!(
    +                    "Failed to inspect directory '{}': {}",
    +                    current.display(),
    +                    e
    +                )));
    +            }
    +        }
    +
    +        if let Ok(canonical) = current.canonicalize() {
    +            if !canonical.starts_with(&canonical_workspace) {
    +                return Err(ZeptoError::SecurityViolation(format!(
    +                    "Directory '{}' resolves outside workspace to '{}'",
    +                    current.display(),
    +                    canonical.display()
    +                )));
    +            }
    +        }
    +    }
    +
    +    Ok(())
    +}
    +
     /// Checks if a file has multiple hard links, which could indicate it aliases
     /// an inode outside the workspace trust boundary.
     ///
    @@ -782,6 +882,32 @@ mod tests {
             );
         }
     
    +    #[test]
    +    fn test_ensure_directory_chain_secure_creates_nested_dirs() {
    +        let temp = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +        let nested = canonical.join("a/b/c");
    +
    +        let result = ensure_directory_chain_secure(&nested, workspace);
    +        assert!(result.is_ok());
    +        assert!(nested.is_dir());
    +    }
    +
    +    #[test]
    +    fn test_ensure_directory_chain_secure_rejects_symlink_parent() {
    +        let temp = tempdir().unwrap();
    +        let outside = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        let linked = canonical.join("linked");
    +        symlink(outside.path(), &linked).unwrap();
    +
    +        let result = ensure_directory_chain_secure(&linked.join("child"), workspace);
    +        assert!(result.is_err());
    +    }
    +
         // ==================== CHECK_HARDLINK_WRITE TESTS ====================
     
         #[test]
    
  • src/tools/filesystem.rs+89 35 modified
    @@ -6,10 +6,16 @@
     
     use async_trait::async_trait;
     use serde_json::{json, Value};
    +#[cfg(unix)]
    +use std::os::unix::fs::OpenOptionsExt;
     use std::path::Path;
    +#[cfg(unix)]
    +use std::{fs::OpenOptions, io::Write as _, os::unix::fs::MetadataExt};
     
     use crate::error::{Result, ZeptoError};
    -use crate::security::{check_hardlink_write, revalidate_path, validate_path_in_workspace};
    +#[cfg(not(unix))]
    +use crate::security::check_hardlink_write;
    +use crate::security::{ensure_directory_chain_secure, revalidate_path, validate_path_in_workspace};
     use crate::tools::diff::apply_unified_diff;
     
     use super::{Tool, ToolCategory, ToolContext, ToolOutput};
    @@ -34,6 +40,85 @@ fn resolve_path(path: &str, ctx: &ToolContext) -> Result<(String, String)> {
         ))
     }
     
    +#[cfg(unix)]
    +fn write_file_secure_blocking(path: &Path, workspace: &str, content: &[u8]) -> Result<()> {
    +    if let Some(parent) = path.parent() {
    +        if !parent.as_os_str().is_empty() {
    +            ensure_directory_chain_secure(parent, workspace)?;
    +            revalidate_path(parent, workspace)?;
    +        }
    +    }
    +
    +    revalidate_path(path, workspace)?;
    +
    +    let mut options = OpenOptions::new();
    +    options
    +        .write(true)
    +        .create(true)
    +        .custom_flags(libc::O_NOFOLLOW);
    +    let mut file = options.open(path).map_err(|e| {
    +        ZeptoError::Tool(format!(
    +            "Failed to securely open file '{}': {}",
    +            path.display(),
    +            e
    +        ))
    +    })?;
    +
    +    let metadata = file.metadata().map_err(|e| {
    +        ZeptoError::Tool(format!(
    +            "Failed to inspect opened file '{}': {}",
    +            path.display(),
    +            e
    +        ))
    +    })?;
    +    if metadata.is_file() && metadata.nlink() > 1 {
    +        return Err(ZeptoError::SecurityViolation(format!(
    +            "Write blocked: '{}' has {} hard links and may alias content outside workspace",
    +            path.display(),
    +            metadata.nlink()
    +        )));
    +    }
    +
    +    file.set_len(0).map_err(|e| {
    +        ZeptoError::Tool(format!(
    +            "Failed to truncate file '{}': {}",
    +            path.display(),
    +            e
    +        ))
    +    })?;
    +    file.write_all(content).map_err(|e| {
    +        ZeptoError::Tool(format!("Failed to write file '{}': {}", path.display(), e))
    +    })?;
    +
    +    Ok(())
    +}
    +
    +#[cfg(not(unix))]
    +fn write_file_secure_blocking(path: &Path, workspace: &str, content: &[u8]) -> Result<()> {
    +    if let Some(parent) = path.parent() {
    +        if !parent.as_os_str().is_empty() {
    +            ensure_directory_chain_secure(parent, workspace)?;
    +            revalidate_path(parent, workspace)?;
    +        }
    +    }
    +
    +    revalidate_path(path, workspace)?;
    +    check_hardlink_write(path)?;
    +    std::fs::write(path, content).map_err(|e| {
    +        ZeptoError::Tool(format!("Failed to write file '{}': {}", path.display(), e))
    +    })?;
    +    Ok(())
    +}
    +
    +async fn write_file_secure(path: &Path, workspace: &str, content: &[u8]) -> Result<()> {
    +    let path = path.to_path_buf();
    +    let workspace = workspace.to_string();
    +    let content = content.to_vec();
    +    tokio::task::spawn_blocking(move || write_file_secure_blocking(&path, &workspace, &content))
    +        .await
    +        .map_err(|e| ZeptoError::Tool(format!("Secure write task failed: {}", e)))?
    +}
    +
     /// Tool for reading file contents.
     ///
     /// Reads the entire contents of a file and returns it as a string.
    @@ -177,24 +262,7 @@ impl Tool for WriteFileTool {
             let (full_path, workspace) = resolve_path(path, ctx)?;
             let full_path_ref = Path::new(&full_path);
     
    -        // TOCTOU: re-validate BEFORE any filesystem mutation (including mkdir)
    -        revalidate_path(full_path_ref, &workspace)?;
    -
    -        // Create parent directories if they don't exist
    -        if let Some(parent) = full_path_ref.parent() {
    -            if !parent.as_os_str().is_empty() {
    -                tokio::fs::create_dir_all(parent).await.map_err(|e| {
    -                    ZeptoError::Tool(format!("Failed to create parent directories: {}", e))
    -                })?;
    -            }
    -        }
    -
    -        // Hardlink check: block writes to files with multiple hard links
    -        check_hardlink_write(full_path_ref)?;
    -
    -        tokio::fs::write(&full_path, content).await.map_err(|e| {
    -            ZeptoError::Tool(format!("Failed to write file '{}': {}", full_path, e))
    -        })?;
    +        write_file_secure(full_path_ref, &workspace, content.as_bytes()).await?;
     
             Ok(ToolOutput::llm_only(format!(
                 "Successfully wrote {} bytes to {}",
    @@ -402,14 +470,7 @@ impl Tool for EditFileTool {
                 let (new_content, summary) = apply_unified_diff(&content, diff_str)
                     .map_err(|e| ZeptoError::Tool(format!("Diff apply failed: {}", e)))?;
     
    -            revalidate_path(full_path_ref, &workspace)?;
    -            check_hardlink_write(full_path_ref)?;
    -
    -            tokio::fs::write(&full_path, &new_content)
    -                .await
    -                .map_err(|e| {
    -                    ZeptoError::Tool(format!("Failed to write file '{}': {}", full_path, e))
    -                })?;
    +            write_file_secure(full_path_ref, &workspace, new_content.as_bytes()).await?;
     
                 Ok(ToolOutput::llm_only(format!(
                     "Applied {} hunk(s): +{} -{} in {}",
    @@ -433,14 +494,7 @@ impl Tool for EditFileTool {
     
                 let new_content = content.replace(old_text, new_text);
     
    -            revalidate_path(full_path_ref, &workspace)?;
    -            check_hardlink_write(full_path_ref)?;
    -
    -            tokio::fs::write(&full_path, &new_content)
    -                .await
    -                .map_err(|e| {
    -                    ZeptoError::Tool(format!("Failed to write file '{}': {}", full_path, e))
    -                })?;
    +            write_file_secure(full_path_ref, &workspace, new_content.as_bytes()).await?;
     
                 let replacements = content.matches(old_text).count();
                 Ok(ToolOutput::llm_only(format!(
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.