Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78aa830430 | ||
|
|
67e4242b9f |
5 changed files with 204 additions and 61 deletions
11
TODO.md
11
TODO.md
|
|
@ -3,17 +3,6 @@
|
||||||
Pick anything from here when relevant. Cross-cutting design notes live in
|
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).
|
[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
|
## Security
|
||||||
|
|
||||||
- **Unprivileged containers (userns mapping).** Today the nspawn container
|
- **Unprivileged containers (userns mapping).** Today the nspawn container
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ You are hyperhive agent `{label}` in a multi-agent system. The operator (recipie
|
||||||
Tools (hyperhive surface):
|
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__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__<server>__<tool>` — 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.
|
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — 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.
|
- `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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,9 @@ impl AgentServer {
|
||||||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
||||||
let log = format!("{args:?}");
|
let log = format!("{args:?}");
|
||||||
let to = args.to.clone();
|
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 {
|
run_tool_envelope("send", log, async move {
|
||||||
let resp = client::request::<_, hive_sh4re::AgentResponse>(
|
let resp = client::request::<_, hive_sh4re::AgentResponse>(
|
||||||
&self.socket,
|
&self.socket,
|
||||||
|
|
@ -627,6 +630,54 @@ pub fn builtin_tools_arg() -> String {
|
||||||
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
|
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
|
||||||
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
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<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 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)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct ExtraMcpServer {
|
struct ExtraMcpServer {
|
||||||
command: String,
|
command: String,
|
||||||
|
|
|
||||||
|
|
@ -380,15 +380,47 @@ async fn build_container_views(
|
||||||
(out, any_stale)
|
(out, any_stale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `/var/lib/hyperhive/meta/flake.lock` into a map of node name
|
/// Map of node name → locked sha for nodes the **root** of meta
|
||||||
/// (`agent-<n>`, `hyperhive`) → locked sha. Missing / unparsable lock
|
/// directly depends on (`hyperhive`, `agent-<n>`). Used by the
|
||||||
/// yields an empty map so the dashboard degrades gracefully when the
|
/// container row to render its `deployed:<sha12>` chip per agent.
|
||||||
/// meta repo hasn't been seeded yet.
|
/// Distinct from `read_meta_inputs()` which walks deeper for the
|
||||||
|
/// flake-input update form.
|
||||||
fn read_meta_locked_revs() -> std::collections::HashMap<String, String> {
|
fn read_meta_locked_revs() -> std::collections::HashMap<String, String> {
|
||||||
read_meta_inputs()
|
let mut out = std::collections::HashMap::new();
|
||||||
.into_iter()
|
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
|
||||||
.map(|i| (i.name, i.rev))
|
return out;
|
||||||
.collect()
|
};
|
||||||
|
let Ok(json) = serde_json::from_str::<serde_json::Value>(&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
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
@ -406,10 +438,20 @@ struct MetaInputView {
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `flake.lock`'s `nodes` map → `Vec<MetaInputView>`. Only
|
/// Walk `flake.lock`'s `nodes` graph from `root` and emit one
|
||||||
/// includes nodes the root depends on (i.e. real inputs), skipping
|
/// `MetaInputView` per fetched input, up to two levels deep. That
|
||||||
/// the synthetic `root` entry. Sorted with `hyperhive` first then
|
/// surfaces the direct meta inputs (`hyperhive`, `agent-<n>`) AND
|
||||||
/// alphabetically so the UI's top entry is the swarm-wide base.
|
/// 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.
|
||||||
fn read_meta_inputs() -> Vec<MetaInputView> {
|
fn read_meta_inputs() -> Vec<MetaInputView> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
|
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
|
||||||
|
|
@ -424,40 +466,9 @@ fn read_meta_inputs() -> Vec<MetaInputView> {
|
||||||
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
|
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
let root_inputs: std::collections::BTreeSet<String> = nodes
|
walk_meta_inputs(nodes, root_name, "", 0, 2, &mut out);
|
||||||
.get(root_name)
|
// hyperhive first, then alphabetical (sub-paths sort under their
|
||||||
.and_then(|n| n.get("inputs"))
|
// parent, which gives a tidy 'agent-foo, agent-foo/bar' grouping).
|
||||||
.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()) {
|
out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
|
||||||
("hyperhive", _) => std::cmp::Ordering::Less,
|
("hyperhive", _) => std::cmp::Ordering::Less,
|
||||||
(_, "hyperhive") => std::cmp::Ordering::Greater,
|
(_, "hyperhive") => std::cmp::Ordering::Greater,
|
||||||
|
|
@ -466,6 +477,65 @@ fn read_meta_inputs() -> Vec<MetaInputView> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn walk_meta_inputs(
|
||||||
|
nodes: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
node_name: &str,
|
||||||
|
prefix: &str,
|
||||||
|
depth: u32,
|
||||||
|
max_depth: u32,
|
||||||
|
out: &mut Vec<MetaInputView>,
|
||||||
|
) {
|
||||||
|
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
|
/// Transient state for agents whose container does NOT yet exist
|
||||||
/// (`Spawning`). Lifecycle ops on existing containers surface as
|
/// (`Spawning`). Lifecycle ops on existing containers surface as
|
||||||
/// `ContainerView.pending` inline; this list only catches pre-creation.
|
/// `ContainerView.pending` inline; this list only catches pre-creation.
|
||||||
|
|
@ -917,11 +987,18 @@ async fn run_meta_update(coord: &Arc<crate::coordinator::Coordinator>, inputs: &
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide which agents to rebuild.
|
// Decide which agents to rebuild. Inputs are slash-paths from
|
||||||
let touched_hyperhive = inputs.iter().any(|i| i == "hyperhive");
|
// 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-<n>/...` only the named agent.
|
||||||
|
let touched_hyperhive = inputs
|
||||||
|
.iter()
|
||||||
|
.any(|i| i == "hyperhive" || i.starts_with("hyperhive/"));
|
||||||
let touched_agents: Vec<String> = inputs
|
let touched_agents: Vec<String> = inputs
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|i| i.strip_prefix("agent-").map(str::to_owned))
|
.filter_map(|i| i.strip_prefix("agent-"))
|
||||||
|
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned())
|
||||||
.collect();
|
.collect();
|
||||||
let agents_to_rebuild: Vec<String> = if touched_hyperhive {
|
let agents_to_rebuild: Vec<String> = if touched_hyperhive {
|
||||||
crate::lifecycle::list()
|
crate::lifecycle::list()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,29 @@
|
||||||
# this. The systemd service that actually runs the harness binary
|
# this. The systemd service that actually runs the harness binary
|
||||||
# differs per role and lives in the child module.
|
# 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 {
|
options.hyperhive.extraMcpServers = lib.mkOption {
|
||||||
type = lib.types.attrsOf (lib.types.submodule {
|
type = lib.types.attrsOf (lib.types.submodule {
|
||||||
options = {
|
options = {
|
||||||
|
|
@ -63,6 +86,9 @@
|
||||||
environment.etc."hyperhive/extra-mcp.json".text =
|
environment.etc."hyperhive/extra-mcp.json".text =
|
||||||
builtins.toJSON config.hyperhive.extraMcpServers;
|
builtins.toJSON config.hyperhive.extraMcpServers;
|
||||||
|
|
||||||
|
environment.etc."hyperhive/send-allow.json".text =
|
||||||
|
builtins.toJSON config.hyperhive.allowedRecipients;
|
||||||
|
|
||||||
boot.isNspawnContainer = true;
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue