hyperhive/hive-agent-mcp/src/send_allow.rs
atlas 6ea81aae65 hive-c0re: render the new agent option paths into generated agent flakes
meta.rs writes each agent's flake, and it still named the pre-move
`hyperhive.*` paths — so every agent rebuild would print a rename
deprecation warning about a line no human wrote and no operator could fix.
A warning nobody can act on trains everyone to ignore the ones that matter,
which is the whole value of the alias shims.

Repoints the FORWARDED_VAR_OPTIONS table and every other emitted option
assignment (otel.*, docs.source, claudeCodePath, github.enable, user.name,
claudeMemoryMaxBytes) to `services.hyperhive.agent.*`, with the test
expectations that pin the rendered text. The flake input named `hyperhive`
(`hyperhive.url`, `hyperhive.inputs.nixpkgs.follows`,
`hyperhive.nixosConfigurations.*`), hive-tier `services.hyperhive.*` paths,
and the `@hyperhive.local` git identity share the word and are untouched.

Also repoints the same option paths where they appear in comments, rustdoc
and runtime message strings across the other crates — a refusal message
naming `hyperhive.allowedRecipients` sends an operator to a path that will
stop existing. Prose under docs/ is deliberately not in this commit.

Refs #4473
2026-09-17 20:19:30 +02:00

58 lines
2.7 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, `<parent>` sentinel
/// always allowed, 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 (e.g. route via `<parent>` instead).
pub fn check_send_allowed(to: &str) -> Result<(), String> {
if to == hive_sh4re::manager::PARENT_RECIPIENT {
// Always allow `<parent>` — the allow-list constrains peer
// chatter, not the structural reporting line; the operator
// can rewire who the parent IS via `set_parent` without
// having to remember to update the per-agent allow-list.
// The broker resolves the sentinel to the real parent label
// on the host side per topology.json (falls back to `operator`
// for root agents).
return Ok(());
}
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:?}. Your structural \
parent is always reachable — route through `send(to: \"{}\", …)` \
if you need to reach someone outside the allow-list.",
hive_sh4re::manager::PARENT_RECIPIENT
))
}