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

ZeptoClaw: Path boundary checks bypass via symlink, TOCTOU, and hardlink

CVE-2026-32232

Description

ZeptoClaw is a personal AI assistant. Prior to 0.7.6, there is a Dangling Symlink Component Bypass, TOCTOU Between Validation and Use, and Hardlink Alias Bypass. 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 contains path traversal vulnerabilities via dangling symlinks, TOCTOU, and hardlink bypass, allowing sandbox escape.

Vulnerability

Analysis

ZeptoClaw is a personal AI assistant written in Rust. Prior to version 0.7.6, its path validation logic contained a critical flaw: it used std::fs::exists() to check path components, which follows symlinks and returns false for dangling symlinks. This allowed a dangling symlink to bypass validation entirely, as the code would skip checking components that did not "exist" according to exists(). Additionally, the description notes a Time-of-Check Time-of-Use (TOCTOU) vulnerability between validation and actual file access, and a hardlink alias bypass that could circumvent workspace boundaries [1][2][3].

Exploitation

An attacker who can create files or symlinks within the assistant's workspace (e.g., through tool execution or file upload) can exploit this by placing a dangling symlink that points to an arbitrary path outside the workspace. When the assistant later accesses that symlink, it resolves to the external target, bypassing the intended sandbox. No authentication is required beyond the ability to interact with the assistant's file system features. The TOCTOU issue allows a race condition where a file is validated as safe but then replaced with a malicious link before use [2][3].

Impact

Successful exploitation allows an attacker to read, write, or execute files outside the designated workspace, effectively escaping the sandbox. This could lead to disclosure of sensitive data, modification of system files, or arbitrary code execution on the host system. The vulnerability is rated as critical due to the potential for full sandbox escape [3].

Mitigation

The vulnerability is fixed in ZeptoClaw version 0.7.6. The fix replaces exists() with symlink_metadata() to properly detect symlinks regardless of their target's existence, and adds additional validation to prevent TOCTOU and hardlink bypasses [2]. Users are strongly advised to update to the latest version. No workarounds are documented.

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

2
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!(
    
f50c17e11ae3

security: harden path validation against dangling symlink escapes (#278)

https://github.com/qhkm/zeptoclawqhkmMar 7, 2026via ghsa
6 files changed · +489 35
  • src/security/mod.rs+1 1 modified
    @@ -14,5 +14,5 @@ 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::{validate_path_in_workspace, SafePath};
    +pub use path::{check_hardlink_write, revalidate_path, validate_path_in_workspace, SafePath};
     pub use shell::{ShellAllowlistMode, ShellSecurityConfig};
    
  • src/security/path.rs+340 22 modified
    @@ -158,36 +158,178 @@ fn check_symlink_escape(path: &Path, canonical_workspace: &Path) -> Result<()> {
         for component in relative.components() {
             current.push(component);
     
    -        // Only check components that exist on the filesystem
    -        if current.exists() {
    -            // Canonicalize to resolve any symlinks
    -            if let Ok(canonical) = current.canonicalize() {
    -                // Check if the canonical path is still within workspace
    -                if !canonical.starts_with(canonical_workspace) {
    -                    log_audit_event(
    -                        AuditCategory::PathSecurity,
    -                        AuditSeverity::Critical,
    -                        "symlink_escape",
    -                        &format!(
    -                            "Symlink escape: '{}' resolves to '{}' outside workspace",
    -                            current.display(),
    -                            canonical.display()
    -                        ),
    -                        true,
    -                    );
    -                    return Err(ZeptoError::SecurityViolation(format!(
    -                        "Symlink escape detected: '{}' resolves to '{}' which is outside workspace",
    -                        current.display(),
    -                        canonical.display()
    -                    )));
    +        // Use symlink_metadata instead of exists() — exists() follows symlinks
    +        // and returns false for dangling symlinks, letting them bypass validation.
    +        // symlink_metadata returns metadata for the symlink itself (lstat).
    +        match std::fs::symlink_metadata(&current) {
    +            Ok(meta) => {
    +                if meta.file_type().is_symlink() {
    +                    // It's a symlink — try to canonicalize to check where it points
    +                    match current.canonicalize() {
    +                        Ok(canonical) => {
    +                            // Symlink resolves — check if target is within workspace
    +                            if !canonical.starts_with(canonical_workspace) {
    +                                log_audit_event(
    +                                    AuditCategory::PathSecurity,
    +                                    AuditSeverity::Critical,
    +                                    "symlink_escape",
    +                                    &format!(
    +                                        "Symlink escape: '{}' resolves to '{}' outside workspace",
    +                                        current.display(),
    +                                        canonical.display()
    +                                    ),
    +                                    true,
    +                                );
    +                                return Err(ZeptoError::SecurityViolation(format!(
    +                                    "Symlink escape detected: '{}' resolves to '{}' which is outside workspace",
    +                                    current.display(),
    +                                    canonical.display()
    +                                )));
    +                            }
    +                        }
    +                        Err(_) => {
    +                            // Dangling symlink — target doesn't exist, so we can't
    +                            // verify it stays within workspace. Reject it since the
    +                            // target could be created or retargeted outside workspace.
    +                            log_audit_event(
    +                                AuditCategory::PathSecurity,
    +                                AuditSeverity::Critical,
    +                                "dangling_symlink",
    +                                &format!(
    +                                    "Dangling symlink: '{}' cannot be resolved",
    +                                    current.display()
    +                                ),
    +                                true,
    +                            );
    +                            return Err(ZeptoError::SecurityViolation(format!(
    +                                "Dangling symlink detected: '{}' target does not exist and cannot be validated",
    +                                current.display()
    +                            )));
    +                        }
    +                    }
    +                } else if meta.is_dir() {
    +                    // Regular directory — canonicalize to check for nested symlinks
    +                    if let Ok(canonical) = current.canonicalize() {
    +                        if !canonical.starts_with(canonical_workspace) {
    +                            log_audit_event(
    +                                AuditCategory::PathSecurity,
    +                                AuditSeverity::Critical,
    +                                "symlink_escape",
    +                                &format!(
    +                                    "Symlink escape: '{}' resolves to '{}' outside workspace",
    +                                    current.display(),
    +                                    canonical.display()
    +                                ),
    +                                true,
    +                            );
    +                            return Err(ZeptoError::SecurityViolation(format!(
    +                                "Symlink escape detected: '{}' resolves to '{}' which is outside workspace",
    +                                current.display(),
    +                                canonical.display()
    +                            )));
    +                        }
    +                    }
                     }
    +                // Regular files: no escape check needed (they can't redirect traversal)
                 }
    +            Err(_) => {
    +                // Path component doesn't exist yet — this is fine for new file creation
    +                // (e.g., writing to workspace/subdir/newfile.txt where newfile.txt doesn't exist)
    +            }
    +        }
    +    }
    +
    +    Ok(())
    +}
    +
    +/// Re-validates a previously validated path immediately before I/O.
    +///
    +/// This narrows the TOCTOU window between validation and use. Call this
    +/// right before every filesystem read/write operation on a path that was
    +/// validated earlier by `validate_path_in_workspace`.
    +///
    +/// Performs:
    +/// 1. Symlink escape check (including dangling symlink detection)
    +/// 2. Workspace boundary check via canonicalization
    +pub fn revalidate_path(path: &Path, workspace: &str) -> Result<()> {
    +    let workspace_path = Path::new(workspace);
    +    let canonical_workspace = workspace_path
    +        .canonicalize()
    +        .unwrap_or_else(|_| normalize_path(workspace_path));
    +
    +    // Re-check symlink escapes (components may have changed since initial validation)
    +    check_symlink_escape(path, &canonical_workspace)?;
    +
    +    // If the path now exists, verify its canonical form is still within workspace
    +    if let Ok(canonical) = path.canonicalize() {
    +        if !canonical.starts_with(&canonical_workspace) {
    +            log_audit_event(
    +                AuditCategory::PathSecurity,
    +                AuditSeverity::Critical,
    +                "toctou_escape",
    +                &format!(
    +                    "Path moved outside workspace between validation and use: '{}' -> '{}'",
    +                    path.display(),
    +                    canonical.display()
    +                ),
    +                true,
    +            );
    +            return Err(ZeptoError::SecurityViolation(format!(
    +                "Path escaped workspace between validation and use: '{}' resolves to '{}'",
    +                path.display(),
    +                canonical.display()
    +            )));
             }
         }
     
         Ok(())
     }
     
    +/// Checks if a file has multiple hard links, which could indicate it aliases
    +/// an inode outside the workspace trust boundary.
    +///
    +/// Call this before write operations on existing files. A file with `nlink > 1`
    +/// inside workspace may be a hardlink to an external inode on the same filesystem,
    +/// allowing writes to escape workspace boundaries.
    +///
    +/// Returns Ok(()) if the file doesn't exist (new file creation) or has exactly 1 link.
    +pub fn check_hardlink_write(path: &Path) -> Result<()> {
    +    use std::os::unix::fs::MetadataExt;
    +
    +    match std::fs::metadata(path) {
    +        Ok(meta) => {
    +            if meta.nlink() > 1 {
    +                log_audit_event(
    +                    AuditCategory::PathSecurity,
    +                    AuditSeverity::Critical,
    +                    "hardlink_escape",
    +                    &format!(
    +                        "File has {} hard links, may alias external inode: '{}'",
    +                        meta.nlink(),
    +                        path.display()
    +                    ),
    +                    true,
    +                );
    +                return Err(ZeptoError::SecurityViolation(format!(
    +                    "Write blocked: '{}' has {} hard links and may alias content outside workspace",
    +                    path.display(),
    +                    meta.nlink()
    +                )));
    +            }
    +            Ok(())
    +        }
    +        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
    +            // File doesn't exist yet — new file creation is fine
    +            Ok(())
    +        }
    +        Err(e) => Err(ZeptoError::Tool(format!(
    +            "Failed to check file metadata for '{}': {}",
    +            path.display(),
    +            e
    +        ))),
    +    }
    +}
    +
     /// Normalizes a path by resolving `.` and `..` components.
     ///
     /// This function processes path components to remove:
    @@ -510,4 +652,180 @@ mod tests {
                 "Should block writing new files through symlinks to outside"
             );
         }
    +
    +    // ==================== DANGLING SYMLINK TESTS ====================
    +
    +    #[test]
    +    fn test_dangling_symlink_rejected() {
    +        let temp = tempdir().unwrap();
    +        // Use canonical workspace to avoid macOS /var -> /private/var mismatch
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a symlink pointing to a non-existent target inside workspace
    +        // (target within workspace namespace so starts_with doesn't mask the check)
    +        let nonexistent_target = canonical.join("does_not_exist_subdir");
    +        let symlink_path = canonical.join("dangling_link");
    +        symlink(&nonexistent_target, &symlink_path).unwrap();
    +
    +        // Dangling symlink should be rejected — target can't be validated
    +        let result = validate_path_in_workspace("dangling_link/file.txt", workspace);
    +        assert!(
    +            result.is_err(),
    +            "Should reject dangling symlinks whose target can't be verified"
    +        );
    +
    +        if let Err(ZeptoError::SecurityViolation(msg)) = result {
    +            assert!(
    +                msg.contains("Dangling symlink") || msg.contains("cannot be validated"),
    +                "Expected dangling symlink error, got: {}",
    +                msg
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn test_dangling_symlink_to_outside_workspace() {
    +        let temp = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a symlink that points outside workspace to a path that doesn't exist
    +        let symlink_path = canonical.join("future_escape");
    +        symlink("/tmp/attacker_controlled_dir_nonexistent", &symlink_path).unwrap();
    +
    +        let result = validate_path_in_workspace("future_escape/secret.txt", workspace);
    +        assert!(
    +            result.is_err(),
    +            "Should reject dangling symlink pointing outside workspace"
    +        );
    +    }
    +
    +    #[test]
    +    fn test_nested_dangling_symlink() {
    +        let temp = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a/dangling where dangling is a broken symlink within workspace namespace
    +        fs::create_dir_all(canonical.join("a")).unwrap();
    +        let nonexistent_target = canonical.join("no_such_dir");
    +        symlink(&nonexistent_target, canonical.join("a/dangling")).unwrap();
    +
    +        let result = validate_path_in_workspace("a/dangling/file.txt", workspace);
    +        assert!(result.is_err(), "Should reject nested dangling symlinks");
    +    }
    +
    +    #[test]
    +    fn test_dangling_symlink_direct_access() {
    +        let temp = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a dangling symlink pointing to non-existent path within workspace
    +        let nonexistent_target = canonical.join("ghost");
    +        let symlink_path = canonical.join("broken_link");
    +        symlink(&nonexistent_target, &symlink_path).unwrap();
    +
    +        // Accessing the symlink itself (not a child) — the symlink is the leaf
    +        let result = validate_path_in_workspace("broken_link", workspace);
    +        assert!(
    +            result.is_err(),
    +            "Should reject direct access to dangling symlink"
    +        );
    +    }
    +
    +    // ==================== REVALIDATE_PATH TESTS ====================
    +
    +    #[test]
    +    fn test_revalidate_path_valid_file() {
    +        let temp = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        let file = canonical.join("safe.txt");
    +        fs::write(&file, "content").unwrap();
    +
    +        // Revalidation should pass for a normal file
    +        let result = revalidate_path(&file, workspace);
    +        assert!(result.is_ok());
    +    }
    +
    +    #[test]
    +    fn test_revalidate_path_nonexistent_file() {
    +        let temp = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Non-existent file — new file creation is fine
    +        let file = canonical.join("new_file.txt");
    +        let result = revalidate_path(&file, workspace);
    +        assert!(result.is_ok());
    +    }
    +
    +    #[test]
    +    fn test_revalidate_path_symlink_escape() {
    +        let temp = tempdir().unwrap();
    +        let outside = tempdir().unwrap();
    +        let canonical = temp.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a symlink pointing outside workspace
    +        let escape = canonical.join("escape");
    +        symlink(outside.path(), &escape).unwrap();
    +
    +        let target = escape.join("secret.txt");
    +        let result = revalidate_path(&target, workspace);
    +        assert!(
    +            result.is_err(),
    +            "Should detect symlink escape on revalidation"
    +        );
    +    }
    +
    +    // ==================== CHECK_HARDLINK_WRITE TESTS ====================
    +
    +    #[test]
    +    fn test_hardlink_write_single_link() {
    +        let temp = tempdir().unwrap();
    +        let file = temp.path().join("single.txt");
    +        fs::write(&file, "content").unwrap();
    +
    +        // Single link (nlink=1) should be allowed
    +        let result = check_hardlink_write(&file);
    +        assert!(result.is_ok());
    +    }
    +
    +    #[test]
    +    fn test_hardlink_write_multiple_links() {
    +        let temp = tempdir().unwrap();
    +        let original = temp.path().join("original.txt");
    +        fs::write(&original, "content").unwrap();
    +
    +        let link = temp.path().join("hardlink.txt");
    +        fs::hard_link(&original, &link).unwrap();
    +
    +        // nlink=2 should be blocked
    +        let result = check_hardlink_write(&link);
    +        assert!(result.is_err());
    +        let err = result.unwrap_err().to_string();
    +        assert!(
    +            err.contains("hard links"),
    +            "Expected hardlink error, got: {}",
    +            err
    +        );
    +
    +        // Original also has nlink=2 now
    +        let result = check_hardlink_write(&original);
    +        assert!(result.is_err());
    +    }
    +
    +    #[test]
    +    fn test_hardlink_write_nonexistent_file() {
    +        let temp = tempdir().unwrap();
    +        let nonexistent = temp.path().join("does_not_exist.txt");
    +
    +        // Non-existent file — new file creation is fine
    +        let result = check_hardlink_write(&nonexistent);
    +        assert!(result.is_ok());
    +    }
     }
    
  • src/tools/docx_read.rs+3 1 modified
    @@ -10,7 +10,7 @@ use serde_json::{json, Value};
     use std::path::PathBuf;
     
     use crate::error::{Result, ZeptoError};
    -use crate::security::validate_path_in_workspace;
    +use crate::security::{revalidate_path, validate_path_in_workspace};
     
     use super::{Tool, ToolContext, ToolOutput};
     
    @@ -50,6 +50,8 @@ impl DocxReadTool {
                     "Only .docx files are supported".to_string(),
                 ));
             }
    +        // TOCTOU: re-validate immediately before I/O
    +        revalidate_path(safe.as_path(), &self.workspace)?;
             if !safe.as_path().exists() {
                 return Err(ZeptoError::Tool(format!("File not found: {path}")));
             }
    
  • src/tools/filesystem.rs+131 9 modified
    @@ -9,7 +9,7 @@ use serde_json::{json, Value};
     use std::path::Path;
     
     use crate::error::{Result, ZeptoError};
    -use crate::security::validate_path_in_workspace;
    +use crate::security::{check_hardlink_write, revalidate_path, validate_path_in_workspace};
     
     use super::{Tool, ToolCategory, ToolContext, ToolOutput};
     
    @@ -18,14 +18,19 @@ use super::{Tool, ToolCategory, ToolContext, ToolOutput};
     /// Requires a workspace to be configured. All paths are validated to stay
     /// within workspace boundaries. This is the correct security posture --
     /// filesystem tools must not operate outside a defined workspace.
    -fn resolve_path(path: &str, ctx: &ToolContext) -> Result<String> {
    +///
    +/// Returns `(resolved_path, workspace)` so callers can re-validate before I/O.
    +fn resolve_path(path: &str, ctx: &ToolContext) -> Result<(String, String)> {
         let workspace = ctx.workspace.as_ref().ok_or_else(|| {
             ZeptoError::SecurityViolation(
                 "Workspace not configured; filesystem tools require a workspace for safety".to_string(),
             )
         })?;
         let safe_path = validate_path_in_workspace(path, workspace)?;
    -    Ok(safe_path.as_path().to_string_lossy().to_string())
    +    Ok((
    +        safe_path.as_path().to_string_lossy().to_string(),
    +        workspace.clone(),
    +    ))
     }
     
     /// Tool for reading file contents.
    @@ -87,7 +92,10 @@ impl Tool for ReadFileTool {
                 .and_then(|v| v.as_str())
                 .ok_or_else(|| ZeptoError::Tool("Missing 'path' argument".into()))?;
     
    -        let full_path = resolve_path(path, ctx)?;
    +        let (full_path, workspace) = resolve_path(path, ctx)?;
    +
    +        // TOCTOU: re-validate immediately before I/O
    +        revalidate_path(Path::new(&full_path), &workspace)?;
     
             let content = tokio::fs::read_to_string(&full_path)
                 .await
    @@ -165,17 +173,24 @@ impl Tool for WriteFileTool {
                 .and_then(|v| v.as_str())
                 .ok_or_else(|| ZeptoError::Tool("Missing 'content' argument".into()))?;
     
    -        let full_path = resolve_path(path, ctx)?;
    +        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) = Path::new(&full_path).parent() {
    +        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))
             })?;
    @@ -246,7 +261,10 @@ impl Tool for ListDirTool {
                 .and_then(|v| v.as_str())
                 .ok_or_else(|| ZeptoError::Tool("Missing 'path' argument".into()))?;
     
    -        let full_path = resolve_path(path, ctx)?;
    +        let (full_path, workspace) = resolve_path(path, ctx)?;
    +
    +        // TOCTOU: re-validate immediately before I/O
    +        revalidate_path(Path::new(&full_path), &workspace)?;
     
             let mut entries = tokio::fs::read_dir(&full_path).await.map_err(|e| {
                 ZeptoError::Tool(format!("Failed to read directory '{}': {}", full_path, e))
    @@ -359,7 +377,11 @@ impl Tool for EditFileTool {
                 .and_then(|v| v.as_str())
                 .ok_or_else(|| ZeptoError::Tool("Missing 'new_text' argument".into()))?;
     
    -        let full_path = resolve_path(path, ctx)?;
    +        let (full_path, workspace) = resolve_path(path, ctx)?;
    +        let full_path_ref = Path::new(&full_path);
    +
    +        // TOCTOU: re-validate immediately before read
    +        revalidate_path(full_path_ref, &workspace)?;
     
             // Read the current content
             let content = tokio::fs::read_to_string(&full_path)
    @@ -378,6 +400,10 @@ impl Tool for EditFileTool {
             // Replace the text
             let new_content = content.replace(old_text, new_text);
     
    +        // TOCTOU: re-validate and hardlink check immediately before write
    +        revalidate_path(full_path_ref, &workspace)?;
    +        check_hardlink_write(full_path_ref)?;
    +
             // Write back
             tokio::fs::write(&full_path, &new_content)
                 .await
    @@ -707,7 +733,7 @@ mod tests {
             let ctx = ToolContext::new().with_workspace(workspace);
             let result = resolve_path("relative/path", &ctx);
             assert!(result.is_ok());
    -        let resolved = result.unwrap();
    +        let (resolved, _ws) = result.unwrap();
             // The path should contain "relative/path" and be within workspace
             assert!(resolved.contains("relative/path") || resolved.ends_with("relative/path"));
         }
    @@ -877,4 +903,100 @@ mod tests {
                 err
             );
         }
    +
    +    // ==================== TOCTOU + HARDLINK SECURITY TESTS ====================
    +
    +    #[tokio::test]
    +    async fn test_write_blocks_hardlinked_file() {
    +        let dir = tempdir().unwrap();
    +        let canonical = dir.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a regular file
    +        let original = canonical.join("original.txt");
    +        fs::write(&original, "original content").unwrap();
    +
    +        // Create a hard link to it
    +        let hardlink = canonical.join("hardlink.txt");
    +        fs::hard_link(&original, &hardlink).unwrap();
    +
    +        let tool = WriteFileTool;
    +        let ctx = ToolContext::new().with_workspace(workspace);
    +
    +        // Writing to the hardlinked file should be blocked
    +        let result = tool
    +            .execute(
    +                json!({"path": "hardlink.txt", "content": "malicious"}),
    +                &ctx,
    +            )
    +            .await;
    +
    +        assert!(result.is_err());
    +        let err = result.unwrap_err().to_string();
    +        assert!(
    +            err.contains("hard links"),
    +            "Expected hardlink error, got: {}",
    +            err
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn test_edit_blocks_hardlinked_file() {
    +        let dir = tempdir().unwrap();
    +        let canonical = dir.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a regular file
    +        let original = canonical.join("editable.txt");
    +        fs::write(&original, "Hello World").unwrap();
    +
    +        // Create a hard link
    +        let hardlink = canonical.join("edit_link.txt");
    +        fs::hard_link(&original, &hardlink).unwrap();
    +
    +        let tool = EditFileTool;
    +        let ctx = ToolContext::new().with_workspace(workspace);
    +
    +        let result = tool
    +            .execute(
    +                json!({
    +                    "path": "edit_link.txt",
    +                    "old_text": "Hello",
    +                    "new_text": "Goodbye"
    +                }),
    +                &ctx,
    +            )
    +            .await;
    +
    +        assert!(result.is_err());
    +        let err = result.unwrap_err().to_string();
    +        assert!(
    +            err.contains("hard links"),
    +            "Expected hardlink error, got: {}",
    +            err
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn test_write_allows_single_link_file() {
    +        let dir = tempdir().unwrap();
    +        let canonical = dir.path().canonicalize().unwrap();
    +        let workspace = canonical.to_str().unwrap();
    +
    +        // Create a regular file (nlink = 1)
    +        fs::write(canonical.join("normal.txt"), "original").unwrap();
    +
    +        let tool = WriteFileTool;
    +        let ctx = ToolContext::new().with_workspace(workspace);
    +
    +        let result = tool
    +            .execute(json!({"path": "normal.txt", "content": "updated"}), &ctx)
    +            .await;
    +
    +        assert!(result.is_ok());
    +        assert_eq!(
    +            fs::read_to_string(canonical.join("normal.txt")).unwrap(),
    +            "updated"
    +        );
    +    }
     }
    
  • src/tools/pdf_read.rs+3 1 modified
    @@ -10,7 +10,7 @@ use serde_json::{json, Value};
     use std::path::PathBuf;
     
     use crate::error::{Result, ZeptoError};
    -use crate::security::validate_path_in_workspace;
    +use crate::security::{revalidate_path, validate_path_in_workspace};
     
     use super::{Tool, ToolContext, ToolOutput};
     
    @@ -49,6 +49,8 @@ impl PdfReadTool {
                     "Only .pdf files are supported".to_string(),
                 ));
             }
    +        // TOCTOU: re-validate immediately before I/O
    +        revalidate_path(safe.as_path(), &self.workspace)?;
             if !safe.as_path().exists() {
                 return Err(ZeptoError::Tool(format!("File not found: {path}")));
             }
    
  • src/tools/transcribe.rs+11 1 modified
    @@ -5,7 +5,7 @@ use serde_json::Value;
     use std::path::Path;
     
     use crate::error::{Result, ZeptoError};
    -use crate::security::validate_path_in_workspace;
    +use crate::security::{revalidate_path, validate_path_in_workspace};
     use crate::tools::{Tool, ToolContext, ToolOutput};
     
     /// Maximum file size accepted for transcription (25 MiB).
    @@ -199,6 +199,16 @@ impl Tool for TranscribeTool {
                 }
             };
     
    +        // TOCTOU: re-validate immediately before I/O
    +        if let Some(ws) = &ctx.workspace {
    +            if let Err(e) = revalidate_path(Path::new(&resolved), ws) {
    +                return Ok(ToolOutput::error(format!(
    +                    "Path re-validation failed: {}",
    +                    e
    +                )));
    +            }
    +        }
    +
             match self.transcribe_file(&resolved).await {
                 Ok(text) if text.is_empty() => Ok(ToolOutput::llm_only(
                     "Transcription returned empty (no speech detected)",
    

Vulnerability mechanics

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

References

7

News mentions

0

No linked articles in our index yet.