Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/mcp.nix
atlas f0844f6da3 agent permissions: one set, tool groups included, set at agent creation
mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

- hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution,
  web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`;
  `lifecycle`/`forge` are gone. `Permission::default_for` is the default
  for an agent the swarm sets nothing for.
- The record is `{ "permissions": [...] }`. Missing key, unset field or
  no successful read yet = default; `[]` = none; a failed read after a
  good one keeps the last value.
- hive-agent reads its own key every 10s and builds each turn's --tools,
  --allowedTools and MCP config (rewritten before every turn) from it;
  the subagent daemon reads it on each start/continue. Agents get a
  read-only grant on their own key.
- hive-c0re restarts an agent only when its manage_root_agent changes.
  The import now merges meta/capabilities.json and meta/tool-groups.json
  into one list per agent and deletes both; the "import pending, don't
  read" decision is `ImportOutcome::read_allowed`, table-tested.
- swarm-controller: `GET /api/permissions` (names + default) and an
  optional `permissions` on `POST /api/agents`, stored before the job is
  queued. swarm-ui's create-agent form gets a permissions field.
- Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon,
  tool_groups.rs, ruth's tool-group seed, the permissions rebuild job
  (PermPayload/WritePermFile/perm_change), the dashboard permissions tab
  and its stale-entries panel.

Part of #5170.
2026-10-15 18:01:40 +02:00

512 lines
25 KiB
Nix

# The MCP tool surface: the built-in hyperhive server (persistent
# streamable-http daemon), the bash-task backend daemon (also a
# persistent streamable-http MCP server, auto-injected into
# `extraMcpServers`), the `extraMcpServers` option itself (stdio or http,
# per entry), and the send-recipient allowlist.
{
pkgs,
lib,
config,
...
}:
let
userName = config.services.hyperhive.agent.user.name;
# Set on the harness only for an ACP agent on the opencode preset
# (./agent-service.nix).
acpApiKeyEnv = config.systemd.services.hive-agent.environment.HIVE_ACP_API_KEY_ENV or null;
# The subagent daemon reads this agent's own queue credential from the
# store, to publish its subagents' terminals as the agent, and on the
# opencode preset the provider key too. Either way it is handed the same
# store identity ./queue-identity.nix hands the harness, under the same
# switch.
subagentReadsStore = config.services.hyperhive.agent.bao.addr != null;
in
{
options.services.hyperhive.agent.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 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.
`operator` is refused either way: agents reach the operator
over matrix.
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.services.hyperhive.agent.extraMcpServers = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
type = lib.mkOption {
type = lib.types.enum [
"stdio"
"http"
];
default = "stdio";
description = ''
Transport for this MCP server. `"stdio"` (the default) spawns
`command` as a fresh child process every turn, talking
JSON-RPC over its stdin/stdout — existing entries need zero
changes to keep this behaviour. `"http"` points claude at a
long-lived streamable-http `url` instead: no per-turn spawn,
no re-registration race, same shape as the built-in
hyperhive surface (`hive-mcp-http`) — use this for a server
backed by an always-on daemon. `command`/`args`/`env` only
apply to `"stdio"`; `url` only to `"http"`.
'';
};
command = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Absolute path to the MCP server binary. Use `''${pkgs.foo}/bin/foo`
or `/run/current-system/sw/bin/foo`. Required when
`type = "stdio"`; ignored (leave `null`) for `"http"`.
'';
};
args = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Args passed to the MCP server binary. `\"stdio\"` only.";
};
env = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Environment variables for the MCP server child process. `\"stdio\"` only.";
};
url = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Streamable-http URL (e.g. `http://127.0.0.1:8791/mcp`) of the
always-on daemon serving this MCP surface. Required when
`type = "http"`; ignored (leave `null`) for `"stdio"`.
'';
};
allowedTools = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "*" ];
example = [
"send_message"
"join_room"
];
description = ''
Tool names this MCP server is auto-approved to call via
`--allowedTools`. Single entry `"*"` (the default) means
"every tool from this server" — convenient but trusting.
Tighten to a specific list when you only want a subset.
Names are bare (e.g. `send_message`); the harness prepends
`mcp__<server-key>__` at build time.
'';
};
availableToSubagents = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether a claude subagent (`mcp__subagent__start`/`continue`,
served by `hive-subagent-daemon`) also gets this MCP server.
Default `false`: a subagent runs `--strict-mcp-config` with no
ambient MCP discovery, so this is the *only* path an
`extraMcpServers` entry reaches a subagent's own claude
invocation at all — leaving it unset keeps a subagent exactly
as narrow as before this option existed (its native built-in
tools only, zero MCP servers).
The automatically injected `bash` and `subagent` entries below stay at
this default — a subagent never gets hive-bash (it already has
claude's native `Bash` tool instead) or the ability to spawn
its own nested subagents, unless an operator opts a
*different* entry in explicitly. Set `true` on an entry (e.g.
a scraper or read-only lookup MCP) to hand it to subagents too.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
matrix = {
command = "/run/current-system/sw/bin/mcp-matrix";
args = [ "--config" "/state/matrix.toml" ];
env.MATRIX_HOMESERVER = "https://matrix.example.org";
allowedTools = [ "send_message" "join_room" ];
};
bash = {
type = "http";
url = "http://127.0.0.1:8791/mcp";
};
}
'';
description = ''
Extra MCP servers claude sees alongside the hyperhive tool surface.
Keys are the server names (claude addresses tools as
`mcp__<key>__<tool>`). Rendered to `/etc/hyperhive/extra-mcp.json`
at activation time; the harness reads that file at boot and merges
it into `--mcp-config` + `--allowedTools`. Take effect on the
agent's next harness restart (no operator approval needed beyond
whatever brought the new agent.nix into deployed/*).
The `bash` entry above is illustrative only — it's auto-injected
below (`services.hyperhive.agent.extraMcpServers.bash` via `lib.mkDefault`) already
tracking `services.hyperhive.agent.mcp.bashHttpPort`, so overriding it directly
with a hardcoded port (as shown) is unusual and will drift if
`bashHttpPort` is changed separately; bump `bashHttpPort` instead.
'';
};
options.services.hyperhive.agent.mcp.httpPort = lib.mkOption {
type = lib.types.port;
default = 8790;
example = 8791;
description = ''
Loopback port the built-in hyperhive MCP surface is served on. HTTP
is the *sole* transport for the built-in surface: a
long-lived `hive-mcp-http` systemd unit runs
`hive-agent-mcp --http 127.0.0.1:<port>` and `render_claude_config`
points claude at the stable `http://127.0.0.1:<port>/mcp` URL. That
URL survives the per-turn claude re-spawn (and a host-side hive-c0re
restart — each tool call dials the control socket fresh), so there
is no per-turn MCP re-registration race (a resumed stdio child could
emit its first tool call before that turn's async
`initialize`/`tools-list` completed, stranding the agent with `No
such tool` — the http endpoint eliminates that). `matrix` stays a
stdio bridge; `bash` runs its own persistent http listener (see
`services.hyperhive.agent.mcp.bashHttpPort`).
Bound loopback-only; the rmcp streamable-http transport's default
`allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host
headers from anywhere else, so no auth token is required for a
container-local endpoint.
Failure-mode note: with no stdio fallback, if `hive-mcp-http` is
down claude hits a dead URL until the unit restarts (guarded by
`Restart=always`, `RestartSec=3`). Intended shape: no per-turn race
while up, a bounded self-healing gap while restarting.
Safe as a single fixed default across all agents: each container
runs in its own private network namespace (isolation is always-on —
see docs/networking/network.md), so `127.0.0.1:<port>` is per-container-private
and cannot collide across agents. Override only if a container-local
service already occupies this port.
Must match `mcp_config::DEFAULT_MCP_HTTP_PORT` (the harness always
exports `HYPERHIVE_MCP_HTTP_PORT`, so the const is only a fallback).
'';
};
options.services.hyperhive.agent.mcp.bashHttpPort = lib.mkOption {
type = lib.types.port;
default = 8791;
example = 8792;
description = ''
Loopback port `hive-bash-daemon` serves its MCP tools
(`run`/`status`/`kill`) on. Same shape as `services.hyperhive.agent.mcp.httpPort`
for the built-in surface: HTTP is the *sole* transport (no stdio
bridge — the daemon that owns the subprocess runner serves the MCP
tools directly in-process), `Restart = "always"` keeps the listener
self-healing, and loopback-only binding means no auth token is
needed (same `allowed_hosts` reasoning as `services.hyperhive.agent.mcp.httpPort`).
Safe as a single fixed default across all agents (private
per-container network namespace — see docs/networking/network.md).
'';
};
options.services.hyperhive.agent.mcp.subagentHttpPort = lib.mkOption {
type = lib.types.port;
default = 8793;
example = 8794;
description = ''
Loopback port `hive-subagent-daemon` serves its MCP tools
(`start`/`continue`/`status`/`interrupt`) on. Independent daemon (own crate,
`hive-subagent-mcp`) — a subagent spawns a full nested `claude`
process, a much heavier capability than a bash command, worth its own
deployable/restartable unit. Same shape/reasoning as
`services.hyperhive.agent.mcp.bashHttpPort` otherwise: sole transport, self-healing
restart, loopback-only so no auth token is needed. Shipped default-on
for every agent today, same as `bash` — expected to become a real
opt-in capability gate later, not yet.
'';
};
config = {
# Assert the transport-specific required field is actually set —
# `command`/`url` are both `nullOr` so the submodule schema stays
# backward-compatible for existing stdio entries, but a `null` in the
# field the chosen `type` actually needs is a config mistake, not a
# valid "unset".
assertions =
lib.mapAttrsToList (name: spec: {
assertion = spec.type != "stdio" || spec.command != null;
message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"stdio\" requires `command` to be set";
}) config.services.hyperhive.agent.extraMcpServers
++ lib.mapAttrsToList (name: spec: {
assertion = spec.type != "http" || spec.url != null;
message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"http\" requires `url` to be set";
}) config.services.hyperhive.agent.extraMcpServers;
# Auto-inject the built-in bash MCP server — always present, every
# agent needs bash tools. `lib.mkDefault` so the operator's own
# agent.nix can override the entry. (The matrix sibling lives in
# ./matrix.nix, gated on that agent having a
# services.hyperhive.agent.matrixAccounts entry at all.) `hive-bash-daemon`
# serves its MCP tools directly over streamable-http (no stdio bridge,
# no round-trip socket) — see the `hive-bash-daemon` service below.
services.hyperhive.agent.extraMcpServers.bash = lib.mkDefault {
type = "http";
url = "http://127.0.0.1:${toString config.services.hyperhive.agent.mcp.bashHttpPort}/mcp";
allowedTools = [ "*" ];
};
# Auto-inject the subagent MCP server — default-on for every agent for
# now (operator's call: "default on for now, should be a capability
# later" — unconditional, where `matrix.nix`'s sibling entry appears only
# for an agent that has a matrix account to serve).
# `lib.mkDefault` so an agent.nix can still override/disable
# the entry in the meantime.
services.hyperhive.agent.extraMcpServers.subagent = lib.mkDefault {
type = "http";
url = "http://127.0.0.1:${toString config.services.hyperhive.agent.mcp.subagentHttpPort}/mcp";
allowedTools = [ "*" ];
};
environment.etc."hyperhive/extra-mcp.json".text =
builtins.toJSON config.services.hyperhive.agent.extraMcpServers;
environment.etc."hyperhive/send-allow.json".text =
builtins.toJSON config.services.hyperhive.agent.allowedRecipients;
# Bash task runner daemon — long-running process that owns subprocess
# monitoring + completion wake signals, and serves the MCP tools
# (`run`/`status`/`kill`) directly over streamable-http on
# `services.hyperhive.agent.mcp.bashHttpPort` — no stdio bridge, no per-turn spawn.
systemd.services.hive-bash-daemon = {
description = "bash task runner + MCP daemon for hive-bash";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
# The daemon runs every bash task via `Command::new("bash")` and the
# commands themselves (hive-forge, git, jq, …) resolve from PATH.
# A standalone daemon has no inherited agent PATH, so without this
# `bash` itself isn't found (spawn fails with ENOENT, the task is
# marked done in 0s with no output / no .out/.err). Mirror the
# harness unit's PATH: NixOS appends `/bin` to each entry →
# /run/wrappers/bin (setuid sudo) + /run/current-system/sw/bin
# (bash, coreutils, hive-forge, …).
path = [
"/run/wrappers"
"/run/current-system/sw"
];
environment = {
# In-agent todo socket the harness serves (loose-ends v2): the
# runner pushes bash-task todos here (upsert while active, keyless
# 'done' on completion) instead of firing a c0re wake. Must match
# the harness's HIVE_AGENT_SOCKET (agent-service.nix).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info";
# HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already
# injected via systemd.globalEnvironment by the meta flake
# (set to /agents/<name>/harness and /agents/<name>/state
# respectively). The daemon uses these to derive its task +
# loose-ends dir paths; `hive_agent_sock::paths::harness_dir`
# panics loudly if HYPERHIVE_HARNESS_DIR is unset rather than
# deriving a fallback, since every service here always gets it.
};
serviceConfig = {
ExecStart = "${config.services.hyperhive.agent.packages.hive-bash-daemon}/bin/hive-bash-daemon --http 127.0.0.1:${toString config.services.hyperhive.agent.mcp.bashHttpPort}";
SyslogIdentifier = "hive-bash-daemon";
# `always` (not `on-failure`): since the MCP tools are served
# in-process now, a down window is total loss of bash tools with
# no stdio fallback — same reasoning as `hive-mcp-http` below.
Restart = "always";
RestartSec = 3;
# systemd's default `stop` tears the whole unit down when the kernel
# kills *any* process in its cgroup, so one OOM-killed task would
# take every running sibling task with it. `continue` scopes the
# loss to the process the kernel chose.
OOMPolicy = "continue";
# The daemon runs each task in its own `tasks/<id>/` cgroup with
# `memory.oom.group=1`, so an OOM kill takes one whole task. The
# subgroup name is `DAEMON_SUBGROUP` in hive-agent-sock's `run_cgroup`.
Delegate = "memory pids";
DelegateSubgroup = "daemon";
User = userName;
Group = userName;
};
};
# Subagent task runner daemon — independent of `hive-bash-daemon` (own
# crate, own process): spawns nested claude sessions on request, serves
# the `start`/`continue`/`status`/`interrupt` MCP tools directly over
# streamable-http on `services.hyperhive.agent.mcp.subagentHttpPort`. The same port also
# serves a second, subagent-facing route (`/signal/mcp/<token>`:
# `goal_reached`/`need_help`) — not something an agent's own config points
# at: the daemon mints each subagent it spawns its own token and writes
# that one URL into that subagent's own `--mcp-config`, which is how a
# signal's identity comes from the endpoint instead of from a `name` the
# caller could have filled in with a sibling's. No task files —
# this daemon's only state is in-memory, live only as long as the process
# is (see `hive-subagent-mcp/src/session.rs`'s module doc); a restart
# stops whatever's running, the actual claude session survives
# independently.
systemd.services.hive-subagent-daemon = {
description = "subagent task runner + MCP daemon for hive-subagent";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
# The live certificate pair exists once ./bao.nix's unit has run.
after = lib.optional subagentReadsStore "hive-agent-bao-identity.service";
# A subagent task runs its own nested `claude` invocation (via
# `hive-claude`), which needs to resolve `claude` itself off PATH —
# same reasoning as `hive-bash-daemon`'s `path` above, even though
# this daemon never shells out to `bash -c` directly.
path = [
"/run/wrappers"
"/run/current-system/sw"
];
environment = {
# Same in-agent todo socket as `hive-bash-daemon` — both push task
# todos to the one harness socket. Must match the harness's
# HIVE_AGENT_SOCKET (agent-service.nix).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info";
# The harness's runtime selection, forwarded from its unit, so an ACP
# agent's subagents run on its ACP agent. All five are absent on a
# claude agent, which the daemon reads as claude.
HIVE_RUNTIME = config.systemd.services.hive-agent.environment.HIVE_RUNTIME or null;
HIVE_ACP_COMMAND = config.systemd.services.hive-agent.environment.HIVE_ACP_COMMAND or null;
HIVE_ACP_ARGS = config.systemd.services.hive-agent.environment.HIVE_ACP_ARGS or null;
HIVE_ACP_ENV = config.systemd.services.hive-agent.environment.HIVE_ACP_ENV or null;
HIVE_ACP_API_KEY_ENV = acpApiKeyEnv;
# Same `services.hyperhive.agent.availableModels` the harness's own assertions gate
# the primary session's model against, so a subagent can't be spawned
# on a model the operator didn't make available to this agent. `null`
# for an empty list, which systemd drops: the daemon reads an absent
# var as "no restriction" (this is a safety rail, not a security
# boundary), matching the harness assertion that an empty list waives
# too.
HIVE_AVAILABLE_MODELS =
if config.services.hyperhive.agent.availableModels == [ ] then
null
else
lib.concatStringsSep "," config.services.hyperhive.agent.availableModels;
# HYPERHIVE_HARNESS_DIR / HYPERHIVE_STATE_DIR: see
# `hive-bash-daemon`'s own comment above — same global injection,
# same reasoning.
# A subagent's backgrounded bash child is reaped with the rest of
# its process tree at turn end, which orphans anything still
# running past the CLI's default 2-minute timeout with a silent
# truncated/empty log rather than a reported failure. Raising the
# default here (not in managed-settings, which the main agent's
# session also reads) scopes the longer timeout to subagent turns
# only — the operator's ruling was explicit that the main agent's
# environment stays as is.
BASH_DEFAULT_TIMEOUT_MS = "1800000"; # 30 minutes
}
// lib.optionalAttrs subagentReadsStore {
# Paths and a name only. `%d` is this unit's own credentials directory.
HIVE_AGENT_NAME = userName;
BAO_ADDR = config.services.hyperhive.agent.bao.addr;
BAO_CLIENT_CERT = config.services.hyperhive.agent.bao.clientCertFile;
BAO_CLIENT_KEY = config.services.hyperhive.agent.bao.clientKeyFile;
BAO_CACERT = "%d/hive-agent-bao-server-ca";
};
serviceConfig = {
ExecStart = "${config.services.hyperhive.agent.packages.hive-subagent-daemon}/bin/hive-subagent-daemon --http 127.0.0.1:${toString config.services.hyperhive.agent.mcp.subagentHttpPort}";
SyslogIdentifier = "hive-subagent-daemon";
# `always`, same reasoning as `hive-bash-daemon`: the MCP tools are
# served in-process, so a down window is total loss of
# `start`/`continue`/`status`/`interrupt` with no stdio fallback.
Restart = "always";
RestartSec = 3;
# The daemon runs each subagent run in its own `runs/<name>-<n>/`
# cgroup with `memory.oom.group=1` and `oom_score_adj` +1000, so an
# OOM kill takes one whole run, ahead of a bash task (0) and
# `hive-agent` (-500). No `OOMScoreAdjust=` here: the daemon stays
# at 0, and a unit value would also be the floor no run could go
# below. The subgroup name is `DAEMON_SUBGROUP` in hive-agent-sock's
# `run_cgroup`.
Delegate = "memory pids";
DelegateSubgroup = "daemon";
# systemd's default is `stop`, which tears the whole unit down as
# soon as the kernel kills *any* process in its cgroup — so one
# over-large subagent takes the daemon and every sibling session
# with it, which is the blast radius that makes an OOM here
# expensive. `continue` scopes the loss to the process the kernel
# actually chose and leaves the daemon serving, which is also what
# lets it report the kill rather than vanish and be restarted.
OOMPolicy = "continue";
# `MemoryHigh=` is a drop-in from ./memory-cap.nix, sized at boot from
# the container's cap.
User = userName;
Group = userName;
}
// lib.optionalAttrs subagentReadsStore {
# Bare id: inherits the credential the container manager passed in,
# the same form ./queue-identity.nix uses.
LoadCredential = [ "hive-agent-bao-server-ca" ];
}
//
lib.optionalAttrs
(
(config.services.hyperhive.agent.runtime == "acp" || config.services.hyperhive.agent.useApiKey)
&& config.services.hyperhive.agent.backendEnvironmentFile != null
)
{
# A subagent authenticates with the same credentials as the
# harness: an ACP subagent to its provider, a claude subagent of
# an api-key agent with `ANTHROPIC_API_KEY`, as it has no OAuth
# session to fall back on.
EnvironmentFile = "-${config.services.hyperhive.agent.backendEnvironmentFile}";
};
};
# Persistent streamable-http MCP daemon for the built-in hyperhive
# surface — the *sole* transport for that surface; always
# wired. Long-lived so claude reconnects to the stable URL each turn
# instead of respawning + re-registering a stdio subprocess (the
# per-turn MCP registration race). It dials the control socket
# (`/run/hive/mcp.sock`, the harness binaries' default) fresh on every
# tool call, so a host-side hive-c0re restart is transparent.
# `before = hive-agent` so the URL is already listening by the time
# the harness renders the first turn's config; the harness/claude also
# reconnect on their own, so ordering is a latency nicety not a hard
# correctness dep.
systemd.services.hive-mcp-http = {
description = "persistent streamable-http MCP daemon for the hyperhive surface";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
environment.RUST_LOG = "info";
# In-agent todo socket the harness serves (loose-ends v2): the
# `get_loose_ends` handler dials it to merge this agent's local todos
# with the static loose-ends from hive-c0re.
environment.HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
serviceConfig = {
ExecStart = "${config.services.hyperhive.agent.packages.hive-agent-mcp}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.services.hyperhive.agent.mcp.httpPort}";
SyslogIdentifier = "hive-mcp-http";
# `always` (not `on-failure`): this endpoint is load-bearing — the
# sole hyperhive-MCP transport, so a down window is total
# hyperhive-MCP loss with no stdio fallback and no per-turn
# self-heal (the URL just stays dead). `always` also covers any
# unforeseen clean-return path and restarts after a stray SIGTERM
# stops it out from under the harness.
Restart = "always";
RestartSec = 3;
User = userName;
Group = userName;
};
};
};
}