An agent with no matrix account has no `mcp__matrix__*` tools, so a refusal that only says "use the matrix MCP tools" points it at something it cannot call. `OPERATOR_SEND_REFUSAL` now names matrix for an agent that has it, and tells one without it that there is no direct channel: the operator reads its `set_status` line, its turn output on the dashboard, and its logs. The allow-list refusal, the `send` tool description, the MCP server instructions and the system prompt say "over matrix if you have it" to match, and the helper-event bullets go back to "ask the operator" without naming a channel.
62 lines
2.6 KiB
Rust
62 lines
2.6 KiB
Rust
//! 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; the manager is always implicitly permitted regardless of
|
|
/// the list contents.
|
|
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<String> = 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}");
|
|
}
|
|
}
|