diff --git a/TODO.md b/TODO.md index fc1d8c45..3cb73324 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,17 @@ 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 094b30d6..88a2d69d 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). 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. +- `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). - (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 14c23118..fa87b57f 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -149,9 +149,6 @@ 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, @@ -630,54 +627,6 @@ 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/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index cdf4be3b..c7bf632b 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -380,47 +380,15 @@ async fn build_container_views( (out, any_stale) } -/// Map of node name → locked sha for nodes the **root** of meta -/// directly depends on (`hyperhive`, `agent-`). Used by the -/// container row to render its `deployed:` chip per agent. -/// Distinct from `read_meta_inputs()` which walks deeper for the -/// flake-input update form. +/// Parse `/var/lib/hyperhive/meta/flake.lock` into a map of node name +/// (`agent-`, `hyperhive`) → locked sha. Missing / unparsable lock +/// yields an empty map so the dashboard degrades gracefully when the +/// meta repo hasn't been seeded yet. fn read_meta_locked_revs() -> std::collections::HashMap { - let mut out = std::collections::HashMap::new(); - let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { - return out; - }; - let Ok(json) = serde_json::from_str::(&raw) else { - return out; - }; - let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { - return out; - }; - let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { - return out; - }; - let Some(root_inputs) = nodes - .get(root_name) - .and_then(|n| n.get("inputs")) - .and_then(|v| v.as_object()) - else { - return out; - }; - for alias in root_inputs.keys() { - let target_name = match root_inputs.get(alias) { - Some(serde_json::Value::String(s)) => s.clone(), - _ => continue, - }; - if let Some(rev) = nodes - .get(&target_name) - .and_then(|n| n.get("locked")) - .and_then(|v| v.get("rev")) - .and_then(|v| v.as_str()) - { - out.insert(alias.clone(), rev.to_owned()); - } - } - out + read_meta_inputs() + .into_iter() + .map(|i| (i.name, i.rev)) + .collect() } #[derive(Serialize, Clone)] @@ -438,20 +406,10 @@ struct MetaInputView { url: Option, } -/// Walk `flake.lock`'s `nodes` graph from `root` and emit one -/// `MetaInputView` per fetched input, up to two levels deep. That -/// surfaces the direct meta inputs (`hyperhive`, `agent-`) AND -/// the agent flakes' own inputs (`agent-dmatrix/mcp-matrix`, -/// `hyperhive/nixpkgs`, etc.) so the operator can bump them -/// individually from the UI. Deeper transitive nodes aren't shown -/// to keep the panel readable — bumping the level-2 entry will -/// re-fetch its own sub-inputs anyway. Names are slash-separated -/// paths from root, which is the syntax `nix flake update` accepts -/// for transitive inputs. -/// -/// Inputs that resolve via a `follows` chain (lock value is an -/// Array of strings) are skipped — they're aliases, not their own -/// fetched derivation, and updating them does nothing. +/// Walk `flake.lock`'s `nodes` map → `Vec`. Only +/// includes nodes the root depends on (i.e. real inputs), skipping +/// the synthetic `root` entry. Sorted with `hyperhive` first then +/// alphabetically so the UI's top entry is the swarm-wide base. fn read_meta_inputs() -> Vec { let mut out = Vec::new(); let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { @@ -466,9 +424,40 @@ fn read_meta_inputs() -> Vec { let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { return out; }; - walk_meta_inputs(nodes, root_name, "", 0, 2, &mut out); - // hyperhive first, then alphabetical (sub-paths sort under their - // parent, which gives a tidy 'agent-foo, agent-foo/bar' grouping). + let root_inputs: std::collections::BTreeSet = nodes + .get(root_name) + .and_then(|n| n.get("inputs")) + .and_then(|v| v.as_object()) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + for (name, node) in nodes { + if !root_inputs.contains(name) { + continue; + } + let locked = node.get("locked"); + let Some(rev) = locked + .and_then(|v| v.get("rev")) + .and_then(|v| v.as_str()) + else { + continue; + }; + let last_modified = locked + .and_then(|v| v.get("lastModified")) + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + let url = node + .get("original") + .and_then(|v| v.get("url")) + .and_then(|v| v.as_str()) + .map(str::to_owned); + out.push(MetaInputView { + name: name.clone(), + rev: rev.to_owned(), + last_modified, + url, + }); + } + // hyperhive first, then alphabetical. out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) { ("hyperhive", _) => std::cmp::Ordering::Less, (_, "hyperhive") => std::cmp::Ordering::Greater, @@ -477,65 +466,6 @@ fn read_meta_inputs() -> Vec { out } -fn walk_meta_inputs( - nodes: &serde_json::Map, - node_name: &str, - prefix: &str, - depth: u32, - max_depth: u32, - out: &mut Vec, -) { - if depth >= max_depth { - return; - } - let Some(node) = nodes.get(node_name) else { - return; - }; - let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else { - return; - }; - for (alias, target) in inputs_map { - // Inputs map value is either a string (node name) or an - // array (a `follows` chain). The latter just aliases another - // node — we can't `nix flake update` it directly, so skip. - let target_name = match target { - serde_json::Value::String(s) => s.clone(), - _ => continue, - }; - let Some(target_node) = nodes.get(&target_name) else { - continue; - }; - let path = if prefix.is_empty() { - alias.clone() - } else { - format!("{prefix}/{alias}") - }; - if let Some(rev) = target_node - .get("locked") - .and_then(|v| v.get("rev")) - .and_then(|v| v.as_str()) - { - let last_modified = target_node - .get("locked") - .and_then(|v| v.get("lastModified")) - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - let url = target_node - .get("original") - .and_then(|v| v.get("url")) - .and_then(|v| v.as_str()) - .map(str::to_owned); - out.push(MetaInputView { - name: path.clone(), - rev: rev.to_owned(), - last_modified, - url, - }); - } - walk_meta_inputs(nodes, &target_name, &path, depth + 1, max_depth, out); - } -} - /// Transient state for agents whose container does NOT yet exist /// (`Spawning`). Lifecycle ops on existing containers surface as /// `ContainerView.pending` inline; this list only catches pre-creation. @@ -987,18 +917,11 @@ async fn run_meta_update(coord: &Arc, inputs: & return; } - // Decide which agents to rebuild. Inputs are slash-paths from - // the meta root — `hyperhive`, `hyperhive/nixpkgs`, - // `agent-coder`, `agent-coder/mcp-matrix`, etc. Anything in the - // hyperhive subtree affects every agent (shared base); anything - // in `agent-/...` only the named agent. - let touched_hyperhive = inputs - .iter() - .any(|i| i == "hyperhive" || i.starts_with("hyperhive/")); + // Decide which agents to rebuild. + let touched_hyperhive = inputs.iter().any(|i| i == "hyperhive"); let touched_agents: Vec = inputs .iter() - .filter_map(|i| i.strip_prefix("agent-")) - .map(|rest| rest.split('/').next().unwrap_or(rest).to_owned()) + .filter_map(|i| i.strip_prefix("agent-").map(str::to_owned)) .collect(); let agents_to_rebuild: Vec = if touched_hyperhive { crate::lifecycle::list() diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 2e682fa4..8aa6c314 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -5,29 +5,6 @@ # 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 = { @@ -86,9 +63,6 @@ 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