From 07921eeff38fbcd6805a05c66d738a6cd77bc7e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 09:53:43 -0400 Subject: [PATCH] fix(send): require explicit --broadcast for AI-tool broadcasts No-@target sends from inside an AI tool silently became broadcasts, and the soft preview guard was bypassed by a habitual --go and skipped entirely for <=3 recipients. Real incident: an agent's empty shell var turned a targeted send into an all-agents broadcast. Broadcast authorization is now its own opt-in: 'hcom send --broadcast'. --go no longer implies everyone; recipient count no longer matters. Human (non-AI-tool) CLI behavior is unchanged. Co-Authored-By: Claude Fable 5 Session: 019f670f-78bf-7b72-95c5-ab7fb1b23700 Producer: ai-session Tool: codex --- src/commands/help.rs | 17 +++++++++++-- src/commands/send.rs | 25 ++++++++++++++++--- tests/cli_smoke.rs | 58 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/commands/help.rs b/src/commands/help.rs index 1b2f71f0..e4b47f86 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -199,7 +199,7 @@ const LIST_HELP: &[HelpEntry] = &[ const SEND_HELP: &[HelpEntry] = &[ (" send @name -- message text", "Direct message"), (" send @name1 @name2 -- message", "Multiple targets"), - (" send -- message text", "Broadcast to all"), + (" send --broadcast -- message", "Broadcast to all"), (" send @name", "Message from stdin (pipe or heredoc)"), (" send @name --file ", "Message from file"), ( @@ -210,6 +210,16 @@ const SEND_HELP: &[HelpEntry] = &[ ("", "Everything after -- is the message (no quotes needed)."), ("", "All flags must come before --."), ("", ""), + ( + " --broadcast", + "Confirm broadcast to everyone (no @target)", + ), + ( + "", + " Required inside AI tool sessions; without it a no-@target", + ), + ("", " send is rejected and prints a preview instead."), + ("", ""), ("Target matching:", ""), (" @luna", "exact base name"), (" @api-luna", "exact full name"), @@ -260,7 +270,10 @@ const SEND_HELP: &[HelpEntry] = &[ " hcom send @luna @nova --intent request -- Can you help?", "", ), - (" hcom send -- Broadcast message to everyone", ""), + ( + " hcom send --broadcast -- Broadcast message to everyone", + "", + ), (" echo 'Complex message' | hcom send @luna", ""), (" hcom send @luna <<'EOF'", ""), (" Multi-line message with special chars", ""), diff --git a/src/commands/send.rs b/src/commands/send.rs index eeed83fc..39c2ced6 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -105,6 +105,11 @@ pub struct SendArgs { #[arg(long)] pub quiet: bool, + /// Explicitly confirm broadcasting to everyone (required for no-@target + /// sends from inside AI tools; --go no longer authorizes broadcasts) + #[arg(long)] + pub broadcast: bool, + // ── Inline bundle ── /// Bundle title (creates inline bundle) #[arg(long)] @@ -368,8 +373,8 @@ fn print_broadcast_preview(db: &HcomDb, delivered_to: &[String]) { println!("Recipients:\n {recipient_list}\n"); println!("Did you mean to send this to everyone?"); println!("Broadcasts can wake many terminals and spend many agents' context/tools."); - println!("\nAdd --go after send and run again to proceed:"); - println!(" hcom send --go ...\n"); + println!("\nIf you really mean everyone, add --broadcast and run again:"); + println!(" hcom send --broadcast ...\n"); } /// @@ -897,11 +902,14 @@ pub fn cmd_send(db: &HcomDb, args: &SendArgs, ctx: Option<&CommandContext>) -> i } }; + // Broadcast from inside an AI tool requires an explicit --broadcast. + // Neither --go nor a small recipient count authorizes it: accidental + // no-@target sends (empty shell var, dropped arg) must fail loudly + // instead of waking every agent. if is_inside_ai_tool() - && !ctx.map(|c| c.go).unwrap_or(false) + && !args.broadcast && preview_delivery.original_scope == MessageScope::Broadcast && !preview_delivery.is_thread_resolved - && preview_delivery.delivered_to.len() > 3 { print_broadcast_preview(db, &preview_delivery.delivered_to); return 1; @@ -1214,6 +1222,15 @@ mod tests { assert_eq!(args.message, vec!["broadcast", "msg"]); } + #[test] + fn parse_broadcast_flag_defaults_off() { + // Broadcast authorization is a dedicated opt-in flag, never implied. + let args = SendArgs::try_parse_from(["send", "--", "hello"]).unwrap(); + assert!(!args.broadcast); + let args = SendArgs::try_parse_from(["send", "--broadcast", "--", "hello"]).unwrap(); + assert!(args.broadcast); + } + #[test] fn parse_with_intent_flag() { let args = diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 15facba2..d29f00af 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -61,6 +61,26 @@ fn help_prints_and_exits_zero() { ); } +#[test] +fn send_help_documents_broadcast_flag() { + // Regression guard: `hcom send --help` must describe the current + // broadcast mechanism (explicit --broadcast required from inside an AI + // tool session), not the pre-fix "send -- message text" / "-- Broadcast + // message to everyone" wording that no longer matches send.rs behavior. + let h = Hcom::new(); + let (code, stdout, stderr) = h.run(["send", "--help"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + assert!(stdout.contains("--broadcast"), "stdout={stdout}"); + assert!( + !stdout.contains("send -- message text"), + "stale no-flag broadcast usage still present: stdout={stdout}" + ); + assert!( + !stdout.contains("hcom send -- Broadcast message to everyone"), + "stale broadcast example still present: stdout={stdout}" + ); +} + #[test] fn status_json_in_fresh_dir() { let h = Hcom::new(); @@ -154,10 +174,12 @@ fn send_strips_redundant_trailing_name_from_auto_resolved_sender() { } #[test] -fn ai_tool_broadcast_to_many_requires_go_preview() { +fn ai_tool_broadcast_requires_explicit_broadcast_flag() { let h = Hcom::new(); let sender = h.start(); - for _ in 0..4 { + // Keep the recipient count below the old >3 preview threshold so this + // regression proves small accidental broadcasts are gated too. + for _ in 0..2 { h.start(); } @@ -179,9 +201,9 @@ fn ai_tool_broadcast_to_many_requires_go_preview() { ); assert!( stdout.contains("BROADCAST SEND PREVIEW") - && stdout.contains("broadcast to 4 agents") + && stdout.contains("broadcast to 2 agents") && stdout.contains("Did you mean to send this to everyone?") - && stdout.contains("hcom send --go"), + && stdout.contains("hcom send --broadcast"), "stdout={stdout}" ); @@ -191,6 +213,8 @@ fn ai_tool_broadcast_to_many_requires_go_preview() { "preview must not send a message: events={events_out}" ); + // --go must no longer authorize a broadcast (regression guard: accidental + // no-@target sends with a habitual --go used to wake every agent). let mut go_cmd = h.cmd(); go_cmd.env("CODEX_SANDBOX", "1").args([ "--go", @@ -198,17 +222,35 @@ fn ai_tool_broadcast_to_many_requires_go_preview() { "--name", &sender, "--", - "confirmed broadcast", + "go does not mean everyone", ]); let go_out = go_cmd.output().expect("spawn hcom --go send"); let go_code = go_out.status.code().unwrap_or(-1); let go_stdout = String::from_utf8_lossy(&go_out.stdout); - let go_stderr = String::from_utf8_lossy(&go_out.stderr); - assert_eq!(go_code, 0, "stdout={go_stdout} stderr={go_stderr}"); + assert_ne!(go_code, 0, "--go must still preview: stdout={go_stdout}"); assert!( - go_stdout.contains("Sent to:") || go_stdout.contains("Sent to 4 agents"), + go_stdout.contains("BROADCAST SEND PREVIEW"), "stdout={go_stdout}" ); + + let mut bc_cmd = h.cmd(); + bc_cmd.env("CODEX_SANDBOX", "1").args([ + "send", + "--broadcast", + "--name", + &sender, + "--", + "confirmed broadcast", + ]); + let bc_out = bc_cmd.output().expect("spawn hcom send --broadcast"); + let bc_code = bc_out.status.code().unwrap_or(-1); + let bc_stdout = String::from_utf8_lossy(&bc_out.stdout); + let bc_stderr = String::from_utf8_lossy(&bc_out.stderr); + assert_eq!(bc_code, 0, "stdout={bc_stdout} stderr={bc_stderr}"); + assert!( + bc_stdout.contains("Sent to:") || bc_stdout.contains("Sent to 2 agents"), + "stdout={bc_stdout}" + ); } #[test]