merge: resolve conflicts between feat/whatsapp-email-channels and main

- Keep main's WhatsApp implementation (webhook-based, simpler)
- Preserve email channel fixes from our branch
- Merge all main branch updates (daemon, cron, health, etc.)
- Resolve Cargo.lock conflicts
This commit is contained in:
argenis de la rosa 2026-02-14 14:59:16 -05:00
commit 4e6da51924
40 changed files with 6925 additions and 780 deletions

View file

@ -29,6 +29,60 @@ impl IMessageChannel {
}
}
/// Escape a string for safe interpolation into `AppleScript`.
///
/// This prevents injection attacks by escaping:
/// - Backslashes (`\` → `\\`)
/// - Double quotes (`"` → `\"`)
fn escape_applescript(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Validate that a target looks like a valid phone number or email address.
///
/// This is a defense-in-depth measure to reject obviously malicious targets
/// before they reach `AppleScript` interpolation.
///
/// Valid patterns:
/// - Phone: starts with `+` followed by digits (with optional spaces/dashes)
/// - Email: contains `@` with alphanumeric chars on both sides
fn is_valid_imessage_target(target: &str) -> bool {
let target = target.trim();
if target.is_empty() {
return false;
}
// Phone number: +1234567890 or +1 234-567-8900
if target.starts_with('+') {
let digits_only: String = target.chars().filter(char::is_ascii_digit).collect();
// Must have at least 7 digits (shortest valid phone numbers)
return digits_only.len() >= 7 && digits_only.len() <= 15;
}
// Email: simple validation (contains @ with chars on both sides)
if let Some(at_pos) = target.find('@') {
let local = &target[..at_pos];
let domain = &target[at_pos + 1..];
// Local part: non-empty, alphanumeric + common email chars
let local_valid = !local.is_empty()
&& local
.chars()
.all(|c| c.is_alphanumeric() || "._+-".contains(c));
// Domain: non-empty, contains a dot, alphanumeric + dots/hyphens
let domain_valid = !domain.is_empty()
&& domain.contains('.')
&& domain
.chars()
.all(|c| c.is_alphanumeric() || ".-".contains(c));
return local_valid && domain_valid;
}
false
}
#[async_trait]
impl Channel for IMessageChannel {
fn name(&self) -> &str {
@ -36,11 +90,22 @@ impl Channel for IMessageChannel {
}
async fn send(&self, message: &str, target: &str) -> anyhow::Result<()> {
let escaped_msg = message.replace('\\', "\\\\").replace('"', "\\\"");
// Defense-in-depth: validate target format before any interpolation
if !is_valid_imessage_target(target) {
anyhow::bail!(
"Invalid iMessage target: must be a phone number (+1234567890) or email (user@example.com)"
);
}
// SECURITY: Escape both message AND target to prevent AppleScript injection
// See: CWE-78 (OS Command Injection)
let escaped_msg = escape_applescript(message);
let escaped_target = escape_applescript(target);
let script = format!(
r#"tell application "Messages"
set targetService to 1st account whose service type = iMessage
set targetBuddy to participant "{target}" of targetService
set targetBuddy to participant "{escaped_target}" of targetService
send "{escaped_msg}" to targetBuddy
end tell"#
);
@ -262,4 +327,204 @@ mod tests {
assert!(ch.is_contact_allowed(" spaced "));
assert!(!ch.is_contact_allowed("spaced"));
}
// ══════════════════════════════════════════════════════════
// AppleScript Escaping Tests (CWE-78 Prevention)
// ══════════════════════════════════════════════════════════
#[test]
fn escape_applescript_double_quotes() {
assert_eq!(escape_applescript(r#"hello "world""#), r#"hello \"world\""#);
}
#[test]
fn escape_applescript_backslashes() {
assert_eq!(escape_applescript(r"path\to\file"), r"path\\to\\file");
}
#[test]
fn escape_applescript_mixed() {
assert_eq!(
escape_applescript(r#"say "hello\" world"#),
r#"say \"hello\\\" world"#
);
}
#[test]
fn escape_applescript_injection_attempt() {
// This is the exact attack vector from the security report
let malicious = r#"" & do shell script "id" & ""#;
let escaped = escape_applescript(malicious);
// After escaping, the quotes should be escaped and not break out
assert_eq!(escaped, r#"\" & do shell script \"id\" & \""#);
// Verify all quotes are now escaped (preceded by backslash)
// The escaped string should not have any unescaped quotes (quote not preceded by backslash)
let chars: Vec<char> = escaped.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' {
// Every quote must be preceded by a backslash
assert!(
i > 0 && chars[i - 1] == '\\',
"Found unescaped quote at position {i}"
);
}
}
}
#[test]
fn escape_applescript_empty_string() {
assert_eq!(escape_applescript(""), "");
}
#[test]
fn escape_applescript_no_special_chars() {
assert_eq!(escape_applescript("hello world"), "hello world");
}
#[test]
fn escape_applescript_unicode() {
assert_eq!(escape_applescript("hello 🦀 world"), "hello 🦀 world");
}
#[test]
fn escape_applescript_newlines_preserved() {
assert_eq!(escape_applescript("line1\nline2"), "line1\nline2");
}
// ══════════════════════════════════════════════════════════
// Target Validation Tests
// ══════════════════════════════════════════════════════════
#[test]
fn valid_phone_number_simple() {
assert!(is_valid_imessage_target("+1234567890"));
}
#[test]
fn valid_phone_number_with_country_code() {
assert!(is_valid_imessage_target("+14155551234"));
}
#[test]
fn valid_phone_number_with_spaces() {
assert!(is_valid_imessage_target("+1 415 555 1234"));
}
#[test]
fn valid_phone_number_with_dashes() {
assert!(is_valid_imessage_target("+1-415-555-1234"));
}
#[test]
fn valid_phone_number_international() {
assert!(is_valid_imessage_target("+447911123456")); // UK
assert!(is_valid_imessage_target("+81312345678")); // Japan
}
#[test]
fn valid_email_simple() {
assert!(is_valid_imessage_target("user@example.com"));
}
#[test]
fn valid_email_with_subdomain() {
assert!(is_valid_imessage_target("user@mail.example.com"));
}
#[test]
fn valid_email_with_plus() {
assert!(is_valid_imessage_target("user+tag@example.com"));
}
#[test]
fn valid_email_with_dots() {
assert!(is_valid_imessage_target("first.last@example.com"));
}
#[test]
fn valid_email_icloud() {
assert!(is_valid_imessage_target("user@icloud.com"));
assert!(is_valid_imessage_target("user@me.com"));
}
#[test]
fn invalid_target_empty() {
assert!(!is_valid_imessage_target(""));
assert!(!is_valid_imessage_target(" "));
}
#[test]
fn invalid_target_no_plus_prefix() {
// Phone numbers must start with +
assert!(!is_valid_imessage_target("1234567890"));
}
#[test]
fn invalid_target_too_short_phone() {
// Less than 7 digits
assert!(!is_valid_imessage_target("+123456"));
}
#[test]
fn invalid_target_too_long_phone() {
// More than 15 digits
assert!(!is_valid_imessage_target("+1234567890123456"));
}
#[test]
fn invalid_target_email_no_at() {
assert!(!is_valid_imessage_target("userexample.com"));
}
#[test]
fn invalid_target_email_no_domain() {
assert!(!is_valid_imessage_target("user@"));
}
#[test]
fn invalid_target_email_no_local() {
assert!(!is_valid_imessage_target("@example.com"));
}
#[test]
fn invalid_target_email_no_dot_in_domain() {
assert!(!is_valid_imessage_target("user@localhost"));
}
#[test]
fn invalid_target_injection_attempt() {
// The exact attack vector from the security report
assert!(!is_valid_imessage_target(r#"" & do shell script "id" & ""#));
}
#[test]
fn invalid_target_applescript_injection() {
// Various injection attempts
assert!(!is_valid_imessage_target(r#"test" & quit"#));
assert!(!is_valid_imessage_target(r#"test\ndo shell script"#));
assert!(!is_valid_imessage_target("test\"; malicious code; \""));
}
#[test]
fn invalid_target_special_chars() {
assert!(!is_valid_imessage_target("user<script>@example.com"));
assert!(!is_valid_imessage_target("user@example.com; rm -rf /"));
}
#[test]
fn invalid_target_null_byte() {
assert!(!is_valid_imessage_target("user\0@example.com"));
}
#[test]
fn invalid_target_newline() {
assert!(!is_valid_imessage_target("user\n@example.com"));
}
#[test]
fn target_with_leading_trailing_whitespace_trimmed() {
// Should trim and validate
assert!(is_valid_imessage_target(" +1234567890 "));
assert!(is_valid_imessage_target(" user@example.com "));
}
}

View file

@ -7,6 +7,7 @@ pub mod slack;
pub mod telegram;
pub mod whatsapp;
pub mod traits;
pub mod whatsapp;
pub use cli::CliChannel;
pub use discord::DiscordChannel;
@ -17,6 +18,7 @@ pub use telegram::TelegramChannel;
#[allow(unused_imports)]
pub use whatsapp::WhatsAppChannel;
pub use traits::Channel;
pub use whatsapp::WhatsAppChannel;
use crate::config::Config;
use crate::memory::{self, Memory};
@ -28,6 +30,46 @@ use std::time::Duration;
/// Maximum characters per injected workspace file (matches `OpenClaw` default).
const BOOTSTRAP_MAX_CHARS: usize = 20_000;
const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
fn spawn_supervised_listener(
ch: Arc<dyn Channel>,
tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
initial_backoff_secs: u64,
max_backoff_secs: u64,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let component = format!("channel:{}", ch.name());
let mut backoff = initial_backoff_secs.max(1);
let max_backoff = max_backoff_secs.max(backoff);
loop {
crate::health::mark_component_ok(&component);
let result = ch.listen(tx.clone()).await;
if tx.is_closed() {
break;
}
match result {
Ok(()) => {
tracing::warn!("Channel {} exited unexpectedly; restarting", ch.name());
crate::health::mark_component_error(&component, "listener exited unexpectedly");
}
Err(e) => {
tracing::error!("Channel {} error: {e}; restarting", ch.name());
crate::health::mark_component_error(&component, e.to_string());
}
}
crate::health::bump_component_restart(&component);
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = backoff.saturating_mul(2).min(max_backoff);
}
})
}
/// Load workspace identity files and build a system prompt.
///
/// Follows the `OpenClaw` framework structure:
@ -150,6 +192,38 @@ pub fn build_system_prompt(
}
}
/// Inject OpenClaw (markdown) identity files into the prompt
fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) {
#[allow(unused_imports)]
use std::fmt::Write;
prompt.push_str("## Project Context\n\n");
prompt
.push_str("The following workspace files define your identity, behavior, and context.\n\n");
let bootstrap_files = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
];
for filename in &bootstrap_files {
inject_workspace_file(prompt, workspace_dir, filename);
}
// BOOTSTRAP.md — only if it exists (first-run ritual)
let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
if bootstrap_path.exists() {
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md");
}
// MEMORY.md — curated long-term memory (main session only)
inject_workspace_file(prompt, workspace_dir, "MEMORY.md");
}
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) {
use std::fmt::Write;
@ -200,6 +274,7 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul
("Webhook", config.channels_config.webhook.is_some()),
("iMessage", config.channels_config.imessage.is_some()),
("Matrix", config.channels_config.matrix.is_some()),
("WhatsApp", config.channels_config.whatsapp.is_some()),
] {
println!(" {} {name}", if configured { "" } else { "" });
}
@ -294,6 +369,18 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
));
}
if let Some(ref wa) = config.channels_config.whatsapp {
channels.push((
"WhatsApp",
Arc::new(WhatsAppChannel::new(
wa.access_token.clone(),
wa.phone_number_id.clone(),
wa.verify_token.clone(),
wa.allowed_numbers.clone(),
)),
));
}
if channels.is_empty() {
println!("No real-time channels configured. Run `zeroclaw onboard` first.");
return Ok(());
@ -338,9 +425,10 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
/// Start all configured channels and route messages to the agent
#[allow(clippy::too_many_lines)]
pub async fn start_channels(config: Config) -> Result<()> {
let provider: Arc<dyn Provider> = Arc::from(providers::create_provider(
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
config.default_provider.as_deref().unwrap_or("openrouter"),
config.api_key.as_deref(),
&config.reliability,
)?);
let model = config
.default_model
@ -359,12 +447,30 @@ pub async fn start_channels(config: Config) -> Result<()> {
// Collect tool descriptions for the prompt
let mut tool_descs: Vec<(&str, &str)> = vec![
("shell", "Execute terminal commands"),
("file_read", "Read file contents"),
("file_write", "Write file contents"),
("memory_store", "Save to memory"),
("memory_recall", "Search memory"),
("memory_forget", "Delete a memory entry"),
(
"shell",
"Execute terminal commands. Use when: running local checks, build/test commands, diagnostics. Don't use when: a safer dedicated tool exists, or command is destructive without approval.",
),
(
"file_read",
"Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
),
(
"file_write",
"Write file contents. Use when: applying focused edits, scaffolding files, updating docs/code. Don't use when: side effects are unclear or file ownership is uncertain.",
),
(
"memory_store",
"Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
),
(
"memory_recall",
"Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
),
(
"memory_forget",
"Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
),
];
if config.browser.enabled {
@ -426,6 +532,15 @@ pub async fn start_channels(config: Config) -> Result<()> {
)));
}
if let Some(ref wa) = config.channels_config.whatsapp {
channels.push(Arc::new(WhatsAppChannel::new(
wa.access_token.clone(),
wa.phone_number_id.clone(),
wa.verify_token.clone(),
wa.allowed_numbers.clone(),
)));
}
if channels.is_empty() {
println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
return Ok(());
@ -450,19 +565,29 @@ pub async fn start_channels(config: Config) -> Result<()> {
println!(" Listening for messages... (Ctrl+C to stop)");
println!();
crate::health::mark_component_ok("channels");
let initial_backoff_secs = config
.reliability
.channel_initial_backoff_secs
.max(DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS);
let max_backoff_secs = config
.reliability
.channel_max_backoff_secs
.max(DEFAULT_CHANNEL_MAX_BACKOFF_SECS);
// Single message bus — all channels send messages here
let (tx, mut rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(100);
// Spawn a listener for each channel
let mut handles = Vec::new();
for ch in &channels {
let ch = ch.clone();
let tx = tx.clone();
handles.push(tokio::spawn(async move {
if let Err(e) = ch.listen(tx).await {
tracing::error!("Channel {} error: {e}", ch.name());
}
}));
handles.push(spawn_supervised_listener(
ch.clone(),
tx.clone(),
initial_backoff_secs,
max_backoff_secs,
));
}
drop(tx); // Drop our copy so rx closes when all channels stop
@ -537,6 +662,8 @@ pub async fn start_channels(config: Config) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tempfile::TempDir;
fn make_workspace() -> TempDir {
@ -781,4 +908,55 @@ mod tests {
let state = classify_health_result(&result);
assert_eq!(state, ChannelHealthState::Timeout);
}
struct AlwaysFailChannel {
name: &'static str,
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Channel for AlwaysFailChannel {
fn name(&self) -> &str {
self.name
}
async fn send(&self, _message: &str, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
async fn listen(
&self,
_tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
) -> anyhow::Result<()> {
self.calls.fetch_add(1, Ordering::SeqCst);
anyhow::bail!("listen boom")
}
}
#[tokio::test]
async fn supervised_listener_marks_error_and_restarts_on_failures() {
let calls = Arc::new(AtomicUsize::new(0));
let channel: Arc<dyn Channel> = Arc::new(AlwaysFailChannel {
name: "test-supervised-fail",
calls: Arc::clone(&calls),
});
let (_tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
let handle = spawn_supervised_listener(channel, _tx, 1, 1);
tokio::time::sleep(Duration::from_millis(80)).await;
drop(rx);
handle.abort();
let _ = handle.await;
let snapshot = crate::health::snapshot_json();
let component = &snapshot["components"]["channel:test-supervised-fail"];
assert_eq!(component["status"], "error");
assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
assert!(component["last_error"]
.as_str()
.unwrap_or("")
.contains("listen boom"));
assert!(calls.load(Ordering::SeqCst) >= 1);
}
}

View file

@ -25,6 +25,13 @@ impl TelegramChannel {
fn is_user_allowed(&self, username: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == username)
}
fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool
where
I: IntoIterator<Item = &'a str>,
{
identities.into_iter().any(|id| self.is_user_allowed(id))
}
}
#[async_trait]
@ -95,15 +102,28 @@ impl Channel for TelegramChannel {
continue;
};
let username = message
let username_opt = message
.get("from")
.and_then(|f| f.get("username"))
.and_then(|u| u.as_str())
.unwrap_or("unknown");
.and_then(|u| u.as_str());
let username = username_opt.unwrap_or("unknown");
if !self.is_user_allowed(username) {
let user_id = message
.get("from")
.and_then(|f| f.get("id"))
.and_then(serde_json::Value::as_i64);
let user_id_str = user_id.map(|id| id.to_string());
let mut identities = vec![username];
if let Some(ref id) = user_id_str {
identities.push(id.as_str());
}
if !self.is_any_user_allowed(identities.iter().copied()) {
tracing::warn!(
"Telegram: ignoring message from unauthorized user: {username}"
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
user_id_str.as_deref().unwrap_or("unknown")
);
continue;
}
@ -211,4 +231,16 @@ mod tests {
assert!(ch.is_user_allowed("bob"));
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn telegram_user_allowed_by_numeric_id_identity() {
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]);
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
}
#[test]
fn telegram_user_denied_when_none_of_identities_match() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
}
}

File diff suppressed because it is too large Load diff