diff --git a/TODO.md b/TODO.md index 3cb7332..fc1d8c4 100644 --- a/TODO.md +++ b/TODO.md @@ -3,17 +3,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in [CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md). -## Permissions / policy - -- **Per-agent send allow-list.** Today any agent can `send` to any - other recipient (peer, manager, operator). Add a per-agent - policy that constrains the `to` field — declared in `agent.nix`, - e.g. `hyperhive.allowedRecipients = [ "manager" "alice" ]`. - Broker rejects with an `Err { message }` when the policy denies. - Default: unrestricted (back-compat). The manager can still - always send anywhere. Useful for sandboxing untrusted sub-agents - so they can only talk to the manager, not other sub-agents. - ## Security - **Unprivileged containers (userns mapping).** Today the nspawn container diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 88a2d69..094b30d 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -3,7 +3,7 @@ You are hyperhive agent `{label}` in a multi-agent system. The operator (recipie Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. -- `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). +- `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable. - (some agents only) **extra MCP tools** surfaced as `mcp____` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. - `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question to the human operator on the dashboard. Returns immediately with a question id — do NOT wait inline. When the operator answers, a system message with event `operator_answered { id, question, answer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, or choice between options. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the operator pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` when the decision becomes moot. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index fa87b57..14c2311 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -149,6 +149,9 @@ impl AgentServer { async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); + if let Err(refusal) = check_send_allowed(&to) { + return run_tool_envelope("send", log, async move { refusal }).await; + } run_tool_envelope("send", log, async move { let resp = client::request::<_, hive_sh4re::AgentResponse>( &self.socket, @@ -627,6 +630,54 @@ pub fn builtin_tools_arg() -> String { /// `mcp____` pattern in `--allowedTools`. const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; +/// Where the NixOS module writes the per-agent send allow-list (see +/// `nix/templates/harness-base.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, manager 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 the manager instead). +fn check_send_allowed(to: &str) -> Result<(), String> { + if to == hive_sh4re::MANAGER_AGENT { + // Always allow agents to talk to the manager — otherwise a + // misconfigured allow-list could leave a sub-agent unable + // to ask for help. + 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 = 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 hyperhive.allowedRecipients \ + (configured in agent.nix). Allowed: {allow:?}. The manager is \ + always reachable — route through `send(to: \"manager\", …)` if \ + you need to reach someone outside the allow-list." + )) +} + #[derive(Debug, serde::Deserialize)] struct ExtraMcpServer { command: String, diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 8aa6c31..2e682fa 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -5,6 +5,29 @@ # this. The systemd service that actually runs the harness binary # differs per role and lives in the child module. + options.hyperhive.allowedRecipients = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "alice" "manager" ]; + description = '' + Names this agent is allowed to `send` to via + `mcp__hyperhive__send`. Empty list (the default) means + unrestricted — the agent can message any peer, the + operator, or the manager. Non-empty list constrains the + surface: only the listed names + the manager (always + allowed) get through; anything else returns an error + string to claude without touching the broker. The + operator (`operator`) needs to be in the list if the + agent should be able to surface output on the + dashboard. + + Useful for sandboxing untrusted sub-agents — set + `[ "manager" ]` to scope them to manager-only chatter. + The manager itself is always exempt; this option only + affects sub-agent `send`. + ''; + }; + options.hyperhive.extraMcpServers = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule { options = { @@ -63,6 +86,9 @@ environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; + environment.etc."hyperhive/send-allow.json".text = + builtins.toJSON config.hyperhive.allowedRecipients; + boot.isNspawnContainer = true; # `claude-code` is unfree. Each per-agent container's nixosConfiguration