From 008859da84694b22aa761d2997edc2fdc4c0d5d6 Mon Sep 17 00:00:00 2001 From: Cynthia Rohr Date: Mon, 17 Aug 2026 19:06:45 +0200 Subject: [PATCH] fix: refuse to send an empty message instead of publishing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buzz messages send --content -` reads stdin verbatim, so a command whose pipeline produced nothing — a printf that rendered empty, a heredoc that collapsed, a substitution that failed — published an empty message rather than failing. Only the maximum size was validated; there was no floor. On 2026-08-17 an agent hit this five times across ten hours while trying to report that it was unauthorized for a repo. Every attempt "succeeded" and carried nothing. The words existed — the harness logged the tokens streaming — but what reached the channel was blank. An empty message is worse than no message. It resolves the mention, lands in the thread at the expected moment, and says nothing, so it reads as an acknowledgement from an agent that has stopped working. The real blocker stayed invisible for a working day, and the remedy that behaviour invited was restarting a healthy agent, which would have destroyed its in-flight work on an unrelated investigation. Failing loudly gives the caller something to act on: a non-zero exit an agent can retry against, and an error a human can read, instead of a message that appears delivered. The message names the likely cause, since a refusal that does not send the caller hunting the relay instead of their own pipeline. Whitespace-only is refused on the same grounds — `printf '\n' |` says nothing either. Media without a caption stays legitimate: the check is skipped when --files is non-empty, verified rather than assumed, because a guard that blocks real usage gets deleted rather than fixed. 257 buzz-cli tests pass; fmt and clippy clean. Signed-off-by: Cynthia Rohr (cherry picked from commit f4c696b59bc0f3e1a6863a3e7f58fc2e3620bbb2) --- crates/buzz-cli/src/commands/messages.rs | 7 +- crates/buzz-cli/src/validate.rs | 84 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index ea273336e38..875c6a7063b 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_present, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, @@ -618,6 +618,11 @@ pub async fn cmd_send_message( // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; validate_content_size(&p.content)?; + // A message with neither text nor media is never what the caller meant, and + // publishing it silently is how an agent's blocker stayed invisible for ten + // hours (ENG-4064). Files are checked rather than assumed: an image-only + // message is legitimate and must still go through. + validate_content_present(&p.content, !p.files.is_empty())?; 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..6d154e50453 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -72,6 +72,41 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> { Ok(()) } +/// Refuse to publish a message with no text. +/// +/// `--content -` reads stdin verbatim, so a command whose pipeline produced +/// nothing (a `printf` that rendered empty, a heredoc that collapsed, a failed +/// substitution) sends an empty message rather than failing. On 2026-08-17 an +/// agent did exactly that five times over ten hours while trying to report that +/// it was unauthorized for a repo. Every attempt published successfully and +/// carried nothing. +/// +/// An empty message is worse than no message. It resolves the mention, lands in +/// the thread at the expected moment, and says nothing — so it reads as an +/// acknowledgement from an agent that has stopped working. The blocker stayed +/// invisible, and the remedy that behaviour invited was restarting a healthy +/// agent, which would have destroyed its in-flight work. +/// +/// Failing loudly here gives the caller something it can act on: an agent sees +/// a non-zero exit and can retry, and a human sees an error instead of a +/// message that appears to have been delivered. +/// +/// Callers that legitimately publish without text — an image-only message — +/// must pass `allow_empty` once they have confirmed other content exists. +pub fn validate_content_present(content: &str, allow_empty: bool) -> Result<(), CliError> { + if !allow_empty && content.trim().is_empty() { + return Err(CliError::Usage( + "refusing to send an empty message: content is blank after reading \ + stdin. An empty message is indistinguishable from an acknowledgement \ + and hides whatever you meant to say. If you piped `--content -`, the \ + producing command wrote nothing — check it succeeded. To publish \ + media with no text, pass --files." + .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)] @@ -504,3 +539,52 @@ mod tests { assert!(matches!(err, CliError::Usage(_))); } } + +#[cfg(test)] +mod empty_content_tests { + use super::*; + + // ENG-4064. An agent spent ten hours trying to report that it was + // unauthorized for a repo. Every attempt published successfully and carried + // nothing, so the blocker was invisible and the agent looked wedged. + + #[test] + fn an_empty_message_is_refused() { + assert!(validate_content_present("", false).is_err()); + } + + #[test] + fn whitespace_only_is_refused_too() { + // `printf '\n' | buzz messages send --content -` is the same failure + // wearing a different hat: a producing command that emitted only a + // newline still says nothing. + for blank in ["\n", " ", "\t\n \n"] { + assert!( + validate_content_present(blank, false).is_err(), + "blank content {blank:?} was accepted" + ); + } + } + + #[test] + fn real_content_passes() { + assert!(validate_content_present("autopilot-brain is unauthorized", false).is_ok()); + } + + #[test] + fn an_image_only_message_is_still_allowed() { + // Media with no caption is legitimate. The guard must not block it, or + // it will be removed rather than fixed. + assert!(validate_content_present("", true).is_ok()); + } + + #[test] + fn the_error_says_what_to_check() { + // A refusal that does not name the likely cause sends the caller + // hunting the relay instead of their own pipeline. + let err = validate_content_present("", false).unwrap_err().to_string(); + assert!(err.contains("empty message"), "{err}"); + assert!(err.contains("--content -"), "{err}"); + assert!(err.contains("--files"), "{err}"); + } +}