//! Per-agent `send` allow-list enforcement. Driven by the operator config at //! `/etc/hyperhive/send-allow.json` (written by the NixOS harness module); the //! MCP server calls [`check_send_allowed`] before forwarding a `send` tool call //! so a blocked recipient surfaces as a claude-readable tool result rather than //! a silent drop. /// Where the NixOS module writes the per-agent send allow-list (see /// `nix/agent-modules/mcp.nix`). Empty list = unrestricted (the /// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` /// field. const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; /// Enforce the per-agent send allow-list. Returns `Ok` when the /// recipient is permitted (no list configured, or `to` is in the list); /// returns `Err(refusal)` with a claude-readable string when blocked — /// the harness surfaces the refusal as the tool result so claude knows /// the message didn't land and can react. `operator` is refused whatever /// the list says, with the same matrix-pointing text the broker returns. pub fn check_send_allowed(to: &str) -> Result<(), String> { if to == hive_sh4re::manager::OPERATOR_RECIPIENT { return Err(hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned()); } let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { return Ok(()); // file missing → no policy configured → unrestricted }; let allow: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { tracing::warn!( path = SEND_ALLOW_PATH, error = ?e, "send allow-list parse failed; falling back to unrestricted", ); return Ok(()); } }; if allow.is_empty() { return Ok(()); // empty list = unrestricted (back-compat) } if allow.iter().any(|n| n == to) { return Ok(()); } Err(format!( "send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \ (configured in agent.nix). Allowed: {allow:?}. To reach the operator, use \ the matrix MCP tools if you have them." )) } #[cfg(test)] mod tests { use super::check_send_allowed; #[test] fn operator_is_refused_naming_matrix_and_the_no_matrix_fallback() { let refusal = check_send_allowed(hive_sh4re::manager::OPERATOR_RECIPIENT) .expect_err("operator must be refused"); assert!(refusal.contains("mcp__matrix__"), "{refusal}"); assert!(refusal.contains("set_status"), "{refusal}"); } }