per-agent send allow-list via hyperhive.allowedRecipients
new NixOS option in harness-base.nix: hyperhive.allowedRecipients = [ 'alice' 'manager' ]; # whitelist hyperhive.allowedRecipients = [ ]; # default = unrestricted module writes the list as JSON to /etc/hyperhive/send-allow .json at activation. AgentServer::send reads the file before issuing the broker request; if the list is non-empty and `to` isn't on it, the tool returns a claude-readable refusal string without touching the broker. the manager is always implicitly permitted regardless of the list — otherwise a misconfigured allow-list could strand a sub-agent without an escalation path. enforcement is in the in-container MCP server (not on the host's per-agent socket) because the agent's nix config is the trust boundary anyway — the operator audits agent.nix at deploy time, the activation-time /etc/hyperhive/send-allow .json is r/o under /nix/store, so the agent can't tamper at runtime without going through a new approval. agent prompt mentions the option + tells claude to route through the manager when refused. retires the matching TODO under Permissions / policy.
This commit is contained in:
parent
d1c69b134a
commit
67e4242b9f
4 changed files with 78 additions and 12 deletions
11
TODO.md
11
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
|
||||
|
|
|
|||
|
|
@ -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__<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ impl AgentServer {
|
|||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> 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__<key>__<tool>` 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<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)]
|
||||
struct ExtraMcpServer {
|
||||
command: String,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue