From 9bc055eb79674eb49aca567da7ce467d6d3a5988 Mon Sep 17 00:00:00 2001 From: Andrew Ivie Date: Thu, 13 Aug 2026 16:35:50 -0600 Subject: [PATCH] fix(cli): reject empty message content in messages send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent running `buzz messages send --content -` with a broken or missing stdin producer reads 0 bytes, and every layer reports success: the relay accepts the event, --mention still notifies, and the caller gets a real event_id back — while the recipient sees a blank message. Observed repeatedly in agent fleets (10 confirmed producerless sends over two weeks in one deployment). Guard at the send call site, not in read_or_stdin: that helper has other call sites where empty passthrough is intentional. Attachment-only messages (files with no caption) remain legitimate. Co-Authored-By: Claude Fable 5 Signed-off-by: Andrew Ivie --- crates/buzz-cli/src/commands/messages.rs | 3 +- crates/buzz-cli/src/validate.rs | 43 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..8390d09b6cc 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -6,7 +6,7 @@ use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, - validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, + validate_content_size, validate_hex64, validate_message_content, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, @@ -580,6 +580,7 @@ pub async fn cmd_send_message( // quoting — the source of countless self-inflicted command-substitution // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; + validate_message_content(&p.content, !p.files.is_empty())?; validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..c035c8a0fac 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -72,6 +72,25 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> { Ok(()) } +/// Reject a message whose content is empty or whitespace-only, unless it +/// attaches files (attachment-only messages are legitimate). +/// +/// This closes a silent-failure mode for agent callers: `--content -` with a +/// broken or missing stdin producer reads 0 bytes, the relay accepts the +/// event, mentions still notify, and every layer reports success while the +/// recipient sees a blank message. +pub fn validate_message_content(content: &str, has_files: bool) -> Result<(), CliError> { + if content.trim().is_empty() && !has_files { + return Err(CliError::Usage( + "message content is empty; pass non-empty --content or attach --file \ + (with `--content -`, an empty read usually means the stdin producer \ + sent nothing)" + .to_string(), + )); + } + Ok(()) +} + /// Percent-encode for URL path segments and query parameter values. /// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~ #[cfg(test)] @@ -276,6 +295,30 @@ mod tests { assert!(validate_content_size("").is_ok()); } + // --- validate_message_content --- + + #[test] + fn validate_message_content_nonempty_ok() { + assert!(validate_message_content("hello", false).is_ok()); + } + + #[test] + fn validate_message_content_empty_rejected() { + let err = validate_message_content("", false).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn validate_message_content_whitespace_only_rejected() { + let err = validate_message_content(" \n\t ", false).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn validate_message_content_empty_with_files_ok() { + assert!(validate_message_content("", true).is_ok()); + } + // --- percent_encode --- #[test]