Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
atlas 2026-10-15 04:09:53 +02:00 • committed by mara
commit f0844f6da3
73 changed files with 1578 additions and 2742 deletions

1
Cargo.lock generated
View file

@ -2049,7 +2049,6 @@ dependencies = [
"serde",
"serde_json",
"strum",
"tracing",
]
[[package]]

View file

@ -2,8 +2,8 @@
<!-- vale write-good.Passive = NO -->
Agents are a **flat set**: the capability store scopes which agents can
manage which others.
Agents are a **flat set**: the `manage_root_agent` permission scopes
which agents can manage which others.
<!-- vale write-good.Passive = YES -->
@ -79,7 +79,7 @@ module docs for the exact call chain.
## Hard-coded manager behaviour
The **manager (`ruth`)** differs from other agents in naming/bootstrap,
its socket flavour and its default capability grant; its prompt, tool
its socket flavour and its default permissions; its prompt, tool
allow-list and state dirs work as for every other agent. A hive with
`services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips
its root-agent create/start sweep
@ -107,7 +107,7 @@ apply.
manage any agent's state dir — config isn't authored there, since a
real config change is a PR from a clone), plus RO mounts for
`/applied` (diff against what's deployed) and `/meta` (system-wide
deploy log). That grant is the `ManageRootAgent` capability: ruth
deploy log). That grant is the `manage_root_agent` permission: ruth
holds it by default, and any agent holding it gets the same mounts.
The operator dashboard's `POST /api/meta-update` is the only path
that writes `flake.lock`.
@ -118,11 +118,10 @@ apply.
`hive-agent/src/prompt.rs`'s own module doc). The `role:manager`
blocks are dead in production, exercised only by a unit test
(`filter_role_blocks(SAMPLE, "manager")`).
- **Tool allow-list** — the MCP tools claude sees come from
`HIVE_TOOL_GROUPS` alone, the same mechanism for every agent
([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's wider default
surface is a wider default grant (`ToolGroup::MANAGER_DEFAULT`, seeded
by `auto_update.rs` whenever its groups aren't already set).
- **Tool allow-list** — the MCP tools claude sees come from the agent's
permissions alone, the same mechanism for every agent
([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's default
permissions add only `manage_root_agent` (`Permission::default_for`).
- **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is
injected uniformly via `systemd.globalEnvironment` for every
container including the manager, so all token/state paths resolve

View file

@ -244,7 +244,7 @@ bind-mounted **read-only** (`hive-c0re/src/lifecycle/host_config.rs`
calls `bind_child_agent_dirs` for each entry in
`topology::all_agents()`). it's a copy to *read* another agent's
current config — not an editing surface. An agent without the
capability sees no other agent's config at all.
permission sees no other agent's config at all.
An agent submits a change to another agent's config the same way it
makes any other change: **clone the target agent's config repo from the
@ -252,29 +252,29 @@ forge into its own state dir, commit on a branch, open a PR**, and let
the operator review and merge it. By design, no second, mount-shaped
path reaches the same file without the review.
Agents holding the `manage_root_agent` capability (granted per agent in
Agents holding the `manage_root_agent` permission (granted per agent in
the swarm's `agent-permissions` record; see
`docs/process/conventions.md#capabilities`) get additional host-side bind
`docs/process/conventions.md#permissions`) get additional host-side bind
mounts via `set_nspawn_flags`:
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's
proposed repo, not just direct children. The capability means "may
proposed repo, not just direct children. The permission means "may
manage any agent", so the mounted set is every agent.
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide
deploy flake.
An agent without the capability only has its direct children's config
An agent without the permission only has its direct children's config
dirs.
⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts
an agent, after its current turn, when its capabilities change.
an agent, after its current turn, when its `manage_root_agent` changes.
The root agent holds the capability by default: an agent whose record
leaves `capabilities` unset gets the hive's default, which is
`manage_root_agent` for `ruth` and nothing for every other agent.
Setting `"capabilities": []` for her revokes it.
The root agent holds the permission by default: an agent whose record
leaves `permissions` unset gets the default, which includes
`manage_root_agent` for `ruth` and for no other agent. Setting her
permissions without it revokes it.
Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at
@ -349,8 +349,8 @@ updates the root agent itself.
The system prompt (`hive-agent/prompts/system.md`, rendered by
`hive-agent/src/prompt.rs`) is the **same for every agent**; what
varies is which MCP tools it surfaces (gated by tool groups and
capabilities in `agent.nix`). No `role:manager` block renders only
varies is which MCP tools it surfaces (gated by the agent's permissions,
which the swarm sets). No `role:manager` block renders only
for the root agent. The root agent's role-specific
behaviour comes from its CLAUDE.md / agent-specific instructions, not
the system prompt template.

View file

@ -329,14 +329,14 @@ Under `/var/lib/hyperhive/agents/<name>/`:
### Cross-agent access to state
An agent holding the `ManageRootAgent` capability gets every other
An agent holding the `manage_root_agent` permission gets every other
agent's `state` dir bind-mounted **read-write** and its `config` dir
**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`).
The RW on `state` is deliberate, not an oversight: the holder recovers
other agents, which includes writing into their state (for example
seeding notes, clearing a stuck sentinel) as well as reading it.
This is the **only** cross-agent mount. An agent holding no capability
This is the **only** cross-agent mount. An agent without that permission
sees its own dirs and nothing else.
**`harness` isn't mounted at all.** It holds that agent's own runtime
@ -364,7 +364,7 @@ mount should be writable when it shouldn't.
<!-- vale write-good.Passive = YES -->
Isolation still holds by default: a container has its *own* dirs and,
unless it holds the capability, nothing else.
unless it holds the permission, nothing else.
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never
@ -384,13 +384,9 @@ Contents:
audit trail (one commit per successful deploy or hyperhive bump).
- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`).
Written by `topology::reconcile` on every meta sync; read by
`topology::all_agents`, which is the set the `ManageRootAgent`
capability grants mounts over. The reader also accepts a map-shaped
`topology::all_agents`, which is the set the `manage_root_agent`
permission grants mounts over. The reader also accepts a map-shaped
file carrying a `parent` per agent, and keeps its keys.
- `tool-groups.json` — per-agent MCP tool group grants
(`{ "alice": ["messaging", "inbox", "execution"] }`). Written by
`tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env
var into each agent's container.
- `swarm-resource-limits.json` — the caps the swarm sets for this
hive's agents
(`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`).
@ -415,11 +411,11 @@ Contents:
The root agent has the meta dir RO-mounted at `/meta/`.
Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json`
holds the capabilities the swarm grants this hive's agents, as
holds the permissions the swarm grants this hive's agents, as
`workers::swarm_permissions` last read them from the swarm's
`agent-permissions` bucket (every 30 seconds): a copy, not the
record, which `swarmctl agent permissions` sets. `set_nspawn_flags`
reads it at every container start.
reads it at every container start for `manage_root_agent`.
hive-c0re renders containers onto `meta#<n>` at creation.

View file

@ -176,7 +176,7 @@ it directly as a bulleted list, no follow-up fetch needed.
`GetLooseEnds` takes no target — it always returns the caller's own
rows, on both the agent and manager sockets alike (the manager, `ruth`,
is a normal agent here, just deployed automatically with different
default capabilities). `Reminder` rows always carry `owner == self`.
default permissions). `Reminder` rows always carry `owner == self`.
Per-variant fields:
@ -272,99 +272,76 @@ JetStream permission refusal, a TLS chain failure) rather than a summary.
Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
## Tool groups
## Permissions
A set of named `ToolGroup` values (`hive_sh4re::permissions::ToolGroup`)
determines the MCP tool surface an agent receives, not a hardcoded
binary flavor.
One set of named `Permission` values (`hive_sh4re::permissions::Permission`)
decides what an agent may do: which MCP and built-in tools it gets, and which
host access it has.
| Group | Tools |
| Permission | Grants |
|---|---|
| `messaging` | `send`, `recv`, `ack_until` |
| `meta` | `get_agent_meta` (`set_status` is always-on, see below) |
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
| `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. |
| `lifecycle` | none |
| `forge` | none |
| `web_tools` | none (gates the Claude built-ins `WebFetch`/`WebSearch`, not an MCP tool) |
| `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/tools/bash.md`. |
| `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
| `manage_root_agent` | every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus `/applied` and `/meta` (ro) |
**Always-on tools** — `ToolGroup::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of which
groups it holds. The operator dashboard depends on every agent
being able to report its status chip, and the server-side `SetStatus` handler
has no tool-group check (only length validation), so gating it would only
desync the `--allowedTools` list from what the host actually accepts.
Revoking `meta` therefore drops `get_agent_meta` but never `set_status`.
`mark_todos_done` is here because producers push todos to an agent independent of
whether it holds `inbox` — an agent without that group still needs a way to
clear them.
**Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of its
permissions. The operator dashboard depends on every agent being able to
report its status chip, and the server-side `SetStatus` handler has no
permission check (only length validation), so gating it would only desync
the `--allowedTools` list from what the host actually accepts. Revoking
`meta` therefore drops `get_agent_meta` but never `set_status`.
`mark_todos_done` is here because producers push todos to an agent whether
or not it holds `inbox`, and an agent without it still needs to clear them.
**Config storage** — per-agent tool groups live in
`/var/lib/hyperhive/meta/tool-groups.json` (hive-c0re-owned, committed to the
meta repo alongside `topology.json`). Format: `{ "alice": ["messaging", "meta",
"inbox", "lifecycle"], "bob": ["messaging", "meta", "inbox"] }`. An absent entry
means "use role default." Tool permissions are intentionally NOT configurable
from `agent.nix` — that file changes through config PRs the manager opens, so
letting it declare its own groups would let the manager grant itself any tool by
submitting a config commit, bypassing the operator gate.
**Setting groups** — the operator sets groups via the dashboard or
`hive-c0re::tool_groups::set_groups(name, groups)`. After a change
`meta::sync_agents` commits the updated file; the next agent rebuild picks up
the new `HIVE_TOOL_GROUPS` env var. Agents with no entry get no var.
**Runtime resolution** — at session start the harness reads `HIVE_TOOL_GROUPS`
(a comma-separated list of snake_case group names injected by the meta renderer
from `tool-groups.json`), logging and skipping unrecognised tokens. Falls back
to `ToolGroup::AGENT_DEFAULT` (`messaging`, `meta`, `inbox`, `execution`) when
the var is absent or empty.
**Updating the surface** — when you add a new `#[tool]` fn to `AgentServer`
in `hive-agent-mcp/src/mcp/mod.rs`, add its name to the matching `ToolGroup::tools()`
slice in `hive-sh4re/src/permissions.rs`. That's the single source of truth;
`mcp_config::allowed_mcp_tools` (in `hive-agent/src/mcp_config.rs`) reads it at
session start.
## Capabilities
Capabilities gate system-level access that goes beyond the MCP tool surface —
things an agent can *access*, not just *call*. Parallel to tool groups but
orthogonal: an agent can have a tool group that registers a tool AND a capability
that allows the underlying resource access.
| Capability | Effect |
|---|---|
| `manage_root_agent` | may manage *any* agent: bind-mounts every agent's state (rw) + config (ro) into the holder's container, plus `/applied` and `/meta` (ro). Takes effect when the holder's container restarts, which hive-c0re does itself on a change |
**Where they live** — in the swarm, not on the hive: the `capabilities` field
of the agent's key in the `agent-permissions` KV bucket
**Where they live** — in the swarm, not on the hive: the `permissions` field of
the agent's key in the `agent-permissions` KV bucket
(`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone
writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
(`/api/agents/{name}/permissions`). Unset means the hive's default, which is a
constant in hive-c0re (`capabilities::default_for`): none, except `ruth`, who
gets `manage_root_agent`. `"capabilities": []` grants none.
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
(`permissions` in `POST /api/agents`). A missing key, an unset field, or no
successful read yet means the default (`Permission::default_for`):
`messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent` for
`ruth`. `"permissions": []` grants none.
**How a change applies** — every 30 seconds hive-c0re reads the key of each
agent it runs (`workers/swarm_permissions.rs`) and stores the result in
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo. nspawn
fixes the bind mounts at container start, so when an agent's capabilities
change hive-c0re restarts it after its current turn. A failed read keeps the
last stored value; it never falls back to the default.
**How a change applies** —
- The agent's harness reads its own key every 10 seconds and builds every
turn's `--tools`, `--allowedTools` and MCP config from the last value, so a
change reaches the next turn. An ACP agent's permission check reads it on
each request.
- The subagent daemon reads the key on each `start`/`continue`; a running
subagent keeps its tools.
- hive-c0re reads each of its agents' keys every 30 seconds
(`workers/swarm_permissions.rs`) into
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo.
nspawn fixes the bind mounts at container start, so when an agent's
`manage_root_agent` changes hive-c0re restarts it after its current turn.
**Moving off `meta/capabilities.json`** — on its first boot with this code a
hive copies the file's grants into the stored value, sends them to
swarm-controller once (written only for agents with no key, and only for
agents that hive's wanted state declares), and deletes the file from the meta
repo when the controller answers (`workers/permissions_import.rs`).
Every reader keeps its last good value on a failed read.
**Capability NOT configurable from `agent.nix`** — same reasoning as tool
groups: an agent that could grant its own capabilities via a config commit would
bypass the operator approval gate.
**Moving off the meta repo** — on its first boot with this code a hive copies
`meta/capabilities.json` and `meta/tool-groups.json` into the stored value,
sends them to swarm-controller once as one list per agent (without
`lifecycle` and `forge`, which gave no tools; the controller writes a key
only where there is none, and only for agents that hive's wanted state
declares), and deletes
both files from the meta repo when the controller answers
(`workers/permissions_import.rs`). Until then it reads no keys.
**Adding a new capability** — add a variant to `Capability` in
`hive-sh4re/src/permissions.rs` and to `Capability::ALL`. Implement the access
check where hive-c0re enforces it (today: `set_nspawn_flags` in
`hive-c0re/src/lifecycle/host_config.rs`).
**Not configurable from `agent.nix`** — that file changes through config PRs
an agent can open, so letting it declare its own permissions would let an
agent grant itself any tool by submitting a config commit, bypassing the
operator gate.
**Adding to the set** — add a variant to `Permission` in
`hive-sh4re/src/permissions.rs` and to `Permission::ALL`, and enforce it where
it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`)
goes into the matching `Permission::tools()` slice: that's the single source
of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`)
reads each turn.
## Async forms

View file

@ -71,9 +71,8 @@ Cheap — no build slot:
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
| `DestroyBookkeeping` | the post-destroy tail — meta sync, drop the power intent, notify the manager, rescan, re-emit the tombstone. Same split rationale as `RebuildBookkeeping`/`Swap`. The destructive work is `PurgeState`'s |
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
| `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
| `WritePermFile` | commit `tool-groups.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshot |
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
| `MatrixSweep` | matrix user/space sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless |
| `KnowledgePull` | `/knowledge` pull (`knowledge::pull`): at boot (commits that landed while `hive-c0re` was down), on the swarm knowledge-changed event, and hourly as a fallback. Holds `Resource::KnowledgeTree` (capacity 1), so two pulls never overlap on the working tree; each trigger queues its own pass, which waits for the resource if one is already live. Agentless |
@ -90,7 +89,7 @@ Two further layers protect the meta repo across _windows_ that span multiple
- **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue
resource declared by every node kind that mutates the meta repo — `MetaSync`,
`MetaLock`, `WritePermFile` and `Provision`'s agent registration. Two meta
`MetaLock` and `Provision`'s agent registration. Two meta
mutations can therefore never interleave, so no commit lands inside another
node's window. It's a queue resource rather than a runtime mutex
because a subtree root holds a resource across its whole subtree, which
@ -139,7 +138,6 @@ restart(a..): online a: [Signal→Drain→ if graceful] StopForUpdate(a) → Re
offline a: Reconcile(a) (nothing to stop; Reconcile converges to existing wanted)
start(a..): a: SetWanted(a,Up) → Reconcile(a) (down+stale ⇒ SetWanted(a,Up) → «rebuild subgraph»)
spawn(a): [wanted=Up at deploy] Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a)
perm-change(a): WritePermFile(a) → «rebuild subgraph»
meta-update(inp): MetaLock(inp) →(in-DAG) «rebuild subgraph» per affected agent
boot: (if any rev marker stale) MetaLock(hyperhive) →(in-DAG) «rebuild subgraph» per stale agent;
plus Reconcile(a) for every drifted agent (all ONE DAG)
@ -215,7 +213,7 @@ resources are free. Resources:
(`SetWanted` is a store write, not a container op, but takes the lease anyway
so a power-op DAG's intent write + reconcile is atomic — two racing ops can't
clobber intent before either reconciles.) **Lease-exempt**: `MetaSync`,
`Prebuild`, `Provision`, `MetaLock`, `WritePermFile` — they
`Prebuild`, `Provision`, `MetaLock` — they
touch the store / meta, not the running container, which is exactly why a
stop can land while another DAG's prebuild is still building. Also exempt, for a different
reason, are the rebuild subtree's own members (`StopForUpdate`, `Swap`,

View file

@ -8,10 +8,10 @@ domain, covers host-level detail for one hive.
## What it shows
| route | what |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop; the swarm name in the header links here |
| `/hives` | the hive directory, each hive with its last reported status |
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents, link forge, matrix and GitHub accounts |
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions), link forge, matrix and GitHub accounts |
| `/agents/<name>/terminal` | one agent's live terminal |
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
| `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), read through the controller with a Viewer service-account token |

View file

@ -7,11 +7,12 @@ by default (`lib.mkDefault`, with `allowedTools = ["*"]`) — an `agent.nix`
can override or drop the entry, so this is a default, not an
unconditional grant. Even where the entry is present, the server is only
actually rendered into an agent's claude config when the agent also
holds the **`execution`** tool group: `extra_server_required_group` in
holds the **`execution`** permission: `extra_server_required_permission` in
`hive-agent/src/mcp_config.rs` suppresses it entirely without that
group, so an agent lacking `execution` gets no `mcp__bash__*` tools at
all, not just an unused grant. See "Relationship to the `execution` tool
group" below.
permission, so an agent lacking `execution` gets no `mcp__bash__*` tools at
all, not just an unused grant. The harness rewrites the config each turn,
so a change applies at the next one. See "Relationship to the
`execution` permission" below.
## Tools
@ -122,14 +123,14 @@ unread rooms. An inline `wait_seconds` / `status` observation that already
delivered the result instead clears the keyed todo, so no redundant
loose-end follows.
## Relationship to the `execution` tool group
## Relationship to the `execution` permission
`ToolGroup::Execution` (in `AGENT_DEFAULT`) is the real gate on bash:
`extra_server_required_group` (in `hive-agent/src/mcp_config.rs`)
`Permission::Execution` (in `Permission::DEFAULT`) is the real gate on bash:
`extra_server_required_permission` (in `hive-agent/src/mcp_config.rs`)
controls whether the `bash` MCP server reaches the agent's claude
config at all, suppressing every `mcp__bash__*` tool
when absent — not just leaving them unused. Its own `tools()` returns
`&[]`: `mcp__bash__run`/`status`/`kill` live on the separate `bash` MCP
server that `extra_server_required_group` gates, not on the
server that `extra_server_required_permission` gates, not on the
`mcp__hyperhive__*` allowlist `tools()` builds. Removing `execution`
from an agent's groups **does** remove bash availability.
from an agent's permissions **does** remove bash availability.

View file

@ -399,23 +399,24 @@ particular `--tools` does **not** filter `mcp__*` tools.
**A subagent gets exactly the built-ins its parent agent has, plus one
deliberate exception** — the base resolution
(`hive_sh4re::permissions::subagent_builtin_tools_arg`, wrapping the
harness's own `builtin_tools_for`) from the same `HIVE_TOOL_GROUPS`:
(`hive_sh4re::permissions::subagent_builtin_tools_for`, wrapping the
harness's own `builtin_tools_for`) over the agent's permissions, which the
daemon reads from the swarm on each `start`/`continue`:
`Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`, plus `WebFetch`/
`WebSearch` for an agent granted the `web_tools` tool group and not
otherwise. See
`WebSearch` for an agent granted `web_tools` and not otherwise. A running
subagent keeps the tools it started with. See
[the harness's own allowlist](../turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools)
for what that list contains and why.
The exception: **a subagent gets Claude's built-in `Bash` iff its parent
holds the `execution` tool group** — the group that already grants the
holds the `execution` permission** — the permission that already grants the
parent shell execution, via the out-of-process `bash` MCP server
(`mcp__bash__run`/`status`/`kill`). A subagent has no MCP server of its
own by default (see [MCP servers](#mcp-servers---strict-mcp-config)
below), so there is no `mcp__bash__*` for it to inherit that capability
through; it gets Claude's own tool instead. The rule is the capability
transfers, not the mechanism — the parent itself never gains built-in
`Bash` no matter which groups it holds, only a subagent spawned by an
`Bash` no matter which permissions it holds, only a subagent spawned by an
`execution`-holding parent does.
Inheriting rather than listing is the point: a hardcoded subagent list

View file

@ -70,7 +70,7 @@ Manage agents across the swarm
* `create` — Queue creation of a new agent on a hive in this swarm
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
* `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models
* `permissions` — Show or change the capabilities the swarm grants an agent
* `permissions` — Show or change the permissions the swarm grants an agent
@ -205,23 +205,23 @@ Clear every field for an agent, so it goes back to its own values
## `swarmctl agent permissions`
Show or change the capabilities the swarm grants an agent.
Show or change the permissions the swarm grants an agent.
The hive running the agent reads them every 30 seconds and, when they change, restarts the agent after its current turn. Unset leaves the agent on its hive's default: none, except `ruth`, who gets `manage_root_agent`.
The agent applies its tool permissions at its next turn; the hive running it restarts it, after its current turn, when `manage_root_agent` changes. Unset leaves the agent on the default: `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent` for `ruth`.
**Usage:** `swarmctl agent permissions <COMMAND>`
###### **Subcommands:**
* `get` — Show the capabilities the swarm grants an agent, or that it sets none
* `set` — Replace the capabilities the swarm grants an agent
* `clear` — Clear the agent's permissions, so its hive's default applies again
* `get` — Show the permissions the swarm grants an agent, or that it sets none
* `set` — Replace the permissions the swarm grants an agent
* `clear` — Clear the agent's permissions, so the default applies again
## `swarmctl agent permissions get`
Show the capabilities the swarm grants an agent, or that it sets none
Show the permissions the swarm grants an agent, or that it sets none
**Usage:** `swarmctl agent permissions get [OPTIONS] <AGENT>`
@ -239,9 +239,9 @@ Show the capabilities the swarm grants an agent, or that it sets none
## `swarmctl agent permissions set`
Replace the capabilities the swarm grants an agent
Replace the permissions the swarm grants an agent
**Usage:** `swarmctl agent permissions set [OPTIONS] --capabilities <CAPABILITIES> <AGENT>`
**Usage:** `swarmctl agent permissions set [OPTIONS] --permissions <PERMISSIONS> <AGENT>`
###### **Arguments:**
@ -249,7 +249,7 @@ Replace the capabilities the swarm grants an agent
###### **Options:**
* `--capabilities <CAPABILITIES>` — The capabilities to grant, comma-separated (`manage_root_agent`). `--capabilities ''` grants none
* `--permissions <PERMISSIONS>` — The permissions to grant, comma-separated: any of `messaging`, `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`. `--permissions ''` grants none
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
@ -258,7 +258,7 @@ Replace the capabilities the swarm grants an agent
## `swarmctl agent permissions clear`
Clear the agent's permissions, so its hive's default applies again
Clear the agent's permissions, so the default applies again
**Usage:** `swarmctl agent permissions clear [OPTIONS] <AGENT>`

View file

@ -177,7 +177,7 @@ harness between turns.
One harness serve binary (`hive-agent`, with its `hive-agent-mcp`
sibling), one shared `nix/agent-modules/` tree, one service unit
(`systemd.services.hive-agent`) for all agents. Privilege
differences live server-side in the broker socket (which tool groups
differences live in the agent's swarm permissions and the broker socket (which tools
and manager-surface calls each agent receives).
`agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`.
@ -250,7 +250,7 @@ The rest lives alongside this page, in three topic files:
custom static files, connectivity overrides, claude plugins, cargo message
filtering).
- **[mcp.md](mcp.md)** — the MCP tool surface claude sees: core tools,
privileged tool groups, self-wake, authoritative state, the tool envelope,
permission-gated tools, self-wake, authoritative state, the tool envelope,
and the built-in tool allowlist.
Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages

View file

@ -22,10 +22,11 @@ toggle (default `false`) — see
[`docs/tools/subagent.md`](../tools/subagent.md#mcp-servers-available-to-a-subagent)
for what reaches a subagent's own `--mcp-config` and what doesn't.
Tool groups (`HIVE_TOOL_GROUPS`) gate tool access. The default
preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and
`execution`. The privileged `lifecycle` group
is opt-in via the P3RM1SS10NS tab.
The agent's permissions gate tool access
([`docs/process/conventions.md#permissions`](../process/conventions.md)).
The default (`Permission::DEFAULT`) is `messaging`, `meta`, `inbox` and
`execution`; `swarmctl agent permissions set` changes it, and the next turn
runs on the change.
## Core tools (always available)
@ -106,7 +107,7 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
`homeserver`); omitted for agents with no matrix provisioning. Omit
`name` to query self.
**Always-on, no tool group**: `set_status(text)`, `compact()`,
**Always-on, no permission needed**: `set_status(text)`, `compact()`,
`mark_todos_done(ids)`.
- `set_status` — set a free-text status string visible on the
@ -130,26 +131,23 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
unknown or already-done ids are silently skipped. Dials the same
in-agent todo socket as `cancel_loose_end`'s `todo` kind.
## Privileged tools (by tool group)
## Privileged tools (by permission)
- **Bash execution** (`execution`) — background shell tasks. See
[`docs/tools/bash.md`](../tools/bash.md).
- **Subagent spawning** — nested sessions on the agent's own runtime
(claude or ACP, via `hive-runtime`) as background tasks, shipped
default-on like bash execution; no tool group gates it. See
default-on like bash execution; no permission gates it. See
[`docs/tools/subagent.md`](../tools/subagent.md).
- **Lifecycle + config** (`lifecycle`) — gates nothing.
Config changes go through a forge PR on `agent-configs/<name>` — see
- **Config changes** — no permission: they go through a forge PR on
`agent-configs/<name>` — see
[`docs/agent-lifecycle/config-changes.md`](../agent-lifecycle/config-changes.md).
- **Forge repos** (`forge`) — gates nothing.
- **Web egress** (`web_tools`) — enables Claude's built-in `WebFetch`
and `WebSearch` tools (not MCP tools; added directly to the
`--allowedTools` list). Off by default; add the group in the
P3RM1SS10NS tab and rebuild to enable.
- **Capabilities** — orthogonal to tool groups, set via the P3RM1SS10NS
tab. No capability registers an MCP tool today; the full list and their
effects are in
[`docs/process/conventions.md#capabilities`](../process/conventions.md).
`--allowedTools` list). Off by default.
- **Recovery mounts** (`manage_root_agent`) — no tool: every agent's state
and config mounted into this agent's container. See
[`docs/process/conventions.md#permissions`](../process/conventions.md).
- **Matrix MCP + extra servers** — `mcp__matrix__*` tools and
per-agent extra MCP config. See
[`docs/tools/matrix.md`](../tools/matrix.md).
@ -199,7 +197,7 @@ status hint lives in the wake prompt + UI header, not here.
## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`)
The built-in list and the `--tools` value it resolves to live in
`hive-sh4re` alongside `ToolGroup`, not in the harness, because the
`hive-sh4re` alongside `Permission`, not in the harness, because the
subagent daemon spawns its own `claude` and must resolve the same set —
see [`docs/tools/subagent.md`](../tools/subagent.md).
@ -207,15 +205,15 @@ see [`docs/tools/subagent.md`](../tools/subagent.md).
`Skill` is what makes an installed plugin's `SKILL.md` invokable —
without it, a skill's `description` frontmatter never gets seen by
the model no matter how well it matches the task.
- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added once the
operator enables the `web_tools` tool group — see P3RM1SS10NS tab).
- Permission-gated built-ins: `WebFetch`, `WebSearch` (added once the
agent holds `web_tools`).
- Denied by omission (absent from the harness `--tools` /
`--allowedTools`, so they "literally don't exist" in a harness turn):
`Bash`, `Task`, `NotebookEdit`, `TodoWrite`.
- Additionally in the managed-settings deny list
(`/etc/claude-code/managed-settings.json`, un-overridable): `Task`,
`TodoWrite`. `Bash` **isn't** in the managed deny — see below.
- Allowed MCP tools: as listed above (by tool group).
- Allowed MCP tools: as listed above (by permission).
The autonomous harness disallows `Bash` — shell execution goes
through `mcp__bash__run` (background tasks with structured output +
@ -230,7 +228,7 @@ completion wake landing in the wrong session (the harness inbox) for a
choom-started task; `choom` is an operator action (root or `hive-admin`),
so built-in shell there stays within the existing trust boundary. The bash MCP server
(`run` / `status` / `kill`) uses `allowedTools = ["*"]` so all
`mcp__bash__*` tools are always available regardless of tool groups.
`mcp__bash__*` tools are available whenever the agent holds `execution`.
`WebFetch` / `WebSearch` are off by default; enable the `web_tools`
tool group in the P3RM1SS10NS tab and rebuild the agent to enable them.
`WebFetch` / `WebSearch` are off by default; grant `web_tools`
(`swarmctl agent permissions set`) to enable them from the next turn.

View file

@ -16,14 +16,13 @@ Settings, Core). Every page links back to H0M3, so you're
never more than one step from the hub.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
of your time. It's a single page with five tabs:
of your time. It's a single page with four tabs:
- **4G3NTS** — every agent, live. This is the default tab and the one
you'll check most.
- **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build
lifecycle: the rebuild queue with a live log of whatever is building,
the meta flake inputs you can bump, and the build log history.
- **P3RM1SS10NS** — what tools and system-level access each agent has.
Everything else lives on its own page instead, all reachable from the
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
@ -58,10 +57,10 @@ shows as "needs update."
streaming log of whatever's currently building. Useful right after
merging a config change or bumping a flake input.
**Grant or revoke a tool/capability.** P3RM1SS10NS is a checkbox matrix
— rows are agents, columns are tool groups or capabilities. Nothing
takes effect until you hit **save all** at the bottom of the tab; a
save queues a rebuild for whichever agents actually changed.
**Grant or revoke a permission.** Not on the hive dashboard: an agent's
permissions live in the swarm. Set them with `swarmctl agent permissions`,
or on the swarm UI's create-agent screen when creating the agent; see
[`process/conventions.md`](../process/conventions.md#permissions).
**Read an agent's logs.** Logs aren't in the hive dashboard. Every
hive ships its agents', daemons' and infra containers' journals to the

View file

@ -20,7 +20,7 @@ from the dashboard tab strip.
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
`◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
`◆ BUILD L0GS ◆`. In-page tabs only —
the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the
@ -77,19 +77,15 @@ H0M3 hub (served at
`/core.html`), with minimal chrome: a `← home` back-link and a single pane,
**K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that
cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed` and `tool_groups_changed`
(the latter re-renders the stale-perms sub-section when permission
data changes). (Rebuild queue and meta inputs are dashboard tabs — see
below.)
`tombstones_changed`. (Rebuild queue and meta inputs are dashboard
tabs — see below.)
Per-container CPU, memory and disk load isn't shown in the hive UI. The
same figures go out as OTEL metrics and appear in Grafana (see
[observability](../scheduler/observability.md)). An agent's CPU and memory
caps live per agent in the swarm UI.
**K3PT ST4T3** — two sub-sections on one pane:
*Tombstones*: agents with kept state and **no container** (size + age +
**K3PT ST4T3** — agents with kept state and **no container** (size + age +
claude-creds badge). One action: `PURG3` (wipes state + applied
dirs; `POST /api/purge-tombstone/{name}`).
@ -104,13 +100,6 @@ container is all hive-c0re can actually prove. Fixing it properly
needs a recorded destroy — **deferred** to the swarm-controller /
snapshot-storage rework where the problem changes shape.
*Stale permission entries*: agents with explicit tool-group JSON
entries but no live container — typically renamed or deleted agents
whose entries persisted in `tool-groups.json`. Loaded when the page opens;
autorefreshes on `tool_groups_changed` SSE
events. Each ghost agent gets a `✕ clear perms` button
(`DELETE /api/permissions/{name}`).
## Build tabs (`#rebuild`, `#meta`, `#buildlogs`)
The build lifecycle — rebuild queue, live build log, meta inputs, and
@ -227,74 +216,6 @@ badge with a ticking elapsed-time chip; expanding streams output via
(suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
entry with that id pre-expanded.
## P3RM1SS10NS tab
Per-agent tool-group configuration, rendered as a column-driven
checkbox matrix: rows are agents, columns are the group names fetched
from hive-c0re. The column list is authoritative — adding a new
tool-group to hive-c0re requires no UI change; the new column appears
automatically. Capabilities aren't set here: they're the swarm's,
set with `swarmctl agent permissions` (see
`docs/process/conventions.md#capabilities`).
The snapshot carries `agents` (the full manageable roster — live
containers ∪ agents with an explicit entry) and `effective` (per-agent
explicit-or-role-default values) alongside the explicit `assignments`
map. Rows come from `agents` so **agents on defaults always appear**
(not just those with an explicit entry), and checkboxes reflect the
`effective` values so a default agent shows the groups it actually runs
with rather than blank — which also means saving it won't silently
strip those defaults. The `(default)` badge keys off absence from
`assignments` (no explicit entry).
Fetches fire on tab activation (not page-load) to avoid unnecessary
work when the operator never visits this tab. Live mutations from the
rebuild-queue worker are also pushed via the `tool_groups_changed`
SSE event (same payload shape as the GET endpoint), so an open P3RM1SS10NS tab reflects worker-applied changes
without requiring navigation. Tab-activation re-fetches remain as a
safety net for reconnect windows.
<!-- vale write-good.Passive = NO -->
**T00L GR0UPS** — per-agent tool-group permissions. Tool groups are
named buckets of MCP tools; each agent starts with a role default
(sub-agents: `messaging`, `meta`, `inbox`, `execution` —
`ToolGroup::AGENT_DEFAULT`; hive-c0re seeds the root agent to
`ToolGroup::MANAGER_DEFAULT` — `messaging`, `meta`, `inbox`,
`lifecycle`, `execution`,
that is, every group except `forge` and `web_tools`). Checking /
unchecking stages which groups are active for the agent; the
page-level **save all** button (below) commits it. Columns come from
`GET /api/tool-groups`. hive-c0re queues a rebuild so `HIVE_TOOL_GROUPS`
takes effect.
<!-- vale write-good.Passive = YES -->
The current tool groups are: `messaging`, `meta`, `inbox`, `lifecycle`,
`forge`, `execution`, `web_tools`. All listed in `ToolGroup::ALL` in `hive-sh4re`. The
`web_tools` group is special: it carries no MCP tools; instead it adds
Claude's built-in `WebFetch` and `WebSearch` to `--tools` /
`--allowedTools` for that agent session.
The table's shape: a `.tg-table-wrap` outer scroll container, `thead`
with a label column (`.tg-agent-col`) + one column per group
(`.tg-group-col`). Each tbody row is one agent: a name cell
and its checkbox cells, where each checkbox carries `data-baseline` (its
render-time state) and the row carries `data-agent` for dirty-tracking.
**Saving — one button for the whole page.** No per-row save
buttons exist. A single page-level `.perm-save-bar` with a **save all (N
agents)** button sits at the bottom of the pane, enabled only when some
checkbox diverges from its baseline. Selecting it diffs every checkbox
and POSTs one batch to `POST /api/permissions` as
`{ changes: [ { agent, tool_groups } ] }` — the batch includes only the
agents whose groups changed (an included array fully replaces the
agent's groups).
The batch is atomic: it validates every change first and on any error
rejects the whole POST (`{error}`, nothing applied); a clean 200 (`ok`)
flips the bar to a queued→rebuilding state and re-fetches the table.
Live `tool_groups_changed` events re-render the
matrix unless it has unsaved edits, so an in-progress edit set
isn't clobbered.
## ST4TS page (`/stats.html`)
Hive-wide turn statistics, aggregated across every agent's
@ -733,31 +654,6 @@ below — some endpoints aren't in it yet.
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries).
- `GET /api/tool-groups` — returns `{ groups: Vec<String>,
assignments: BTreeMap<String, Vec<String>>,
descriptions: BTreeMap<String, String> }`. `groups` is the
ordered list of all known tool-group names (drives the column
headers in the P3RM1SS10NS tab); `assignments` is the per-agent
override map (absent agents use the role default); `descriptions`
maps each group name to a short human-readable string surfaced as
a column-header tooltip on hover.
- `POST /api/tool-groups/{agent}` — body `{ groups: ["name", …] }`.
Writes the tool-group set for `{agent}` to
`/var/lib/hyperhive/meta/tool-groups.json` and queues a rebuild so
`HIVE_TOOL_GROUPS` takes effect. Agent name validated;
`guard_agent_name` applied.
- `POST /api/permissions` — batch perm apply for the save-all
permissions button. Body
`{ changes: [{ agent, tool_groups?: ["name", …] }] }`.
An omitted `tool_groups` leaves the agent untouched, an empty array
clears it, a populated array fully replaces it (same replace
semantics as the per-agent endpoint above). Each affected agent gets
one `PermChange` queue entry. **Atomic**: the endpoint validates every
change first (agent names via `guard_agent_name`, group names) and on
any validation error writes or enqueues nothing (non-2xx `{ error }`);
it skips rows with the field omitted rather than erroring on them.
Returns `200 "ok"` on
success.
- `POST /api/meta-update` — `nix flake update` the selected
`meta/flake.lock` inputs, then rebuild the affected agents.
- `GET /api/dashboard/stream` — unified live event channel:
@ -805,11 +701,6 @@ payload):
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`).
- `tool_groups_changed` (seq, groups: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
tool-group assignments. Emitted from the rebuild-queue worker after
a `PermChange` / ToolGroups entry commits the JSON file. Same
shape as `GET /api/tool-groups`; P3RM1SS10NS tab subscribes.
- `tombstones_changed` (seq, tombstones: `Vec<TombstoneView>`) —
full snapshot of destroyed-but-state-kept agents. Emitted on
destroy, purge, and revive. C0R3 K3PT ST4T3 tab subscribes.

View file

@ -13,10 +13,9 @@
<body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones) and the
stale permission entries. Rebuild queue + meta inputs are tabs of
/dashboard.html. The section <div> ids (tombstones-section,
tombstones-stale-perms) match what core.js's renderers target. Hive
dashboard tab strip stays lean: kept state (tombstones). Rebuild
queue + meta inputs are tabs of /dashboard.html. The section <div>
id (tombstones-section) matches what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
@ -26,11 +25,7 @@
</header>
<main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button).
tombstones-stale-perms shows agents with explicit tool-group
entries but no live container (typically renamed/deleted
agents whose JSON entries persisted). Each stale entry gets a
"✕ clear perms" button. -->
<!-- tombstones-section shows destroyed agents (purge button). -->
<p class="meta">
kept state from previously tombstoned agents — recreating an agent with
the same name reuses it.
@ -38,7 +33,6 @@
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<div id="tombstones-stale-perms"></div>
</main>
<script type="module" src="/static/core.js" defer></script>

View file

@ -1,6 +1,6 @@
// C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state
// (tombstones) and the stale permission entries.
// (tombstones).
//
// Rebuild queue + meta inputs are tabs of /dashboard.html, not here. This
// page is its own esbuild bundle with its own runtime: it cold-loads
@ -10,7 +10,7 @@
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
@ -95,92 +95,6 @@ function renderTombstones(s) {
root.append(ul);
}
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// Agents with explicit tool-group JSON entries but no live
// container AND no kept-state tombstone (e.g. renamed agents like the old
// "root" manager name). Ghost detection is server-side via
// GET /api/permissions/stale so the client doesn't need to maintain a
// container-roster cache or perform set arithmetic. Lazy-loaded on first
// "kept" tab activation; refreshed when perm data changes via SSE.
let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) {
root.replaceChildren();
if (!ghosts.length) return;
root.append(
el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
);
root.append(
el(
"p",
{ class: "meta" },
"agents with explicit tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
const ul = el("ul", { class: "tombstones-stale-list" });
for (const name of ghosts) {
const li = el("li", { class: "tombstones-stale-row" });
li.append(el("span", { class: "tombstones-stale-name" }, name));
li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
const btn = el(
"button",
{
type: "button",
class: "btn btn-destroy",
title: "remove explicit tool-group entries for " + name,
},
"✕ clear perms",
);
btn.addEventListener("click", () =>
asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch(
"/api/permissions/" + encodeURIComponent(name),
{ method: "DELETE" },
);
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = "failed to clear perms for " + name + ": " + msg;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = "failed to clear perms for " + name + ": " + err;
errP.hidden = false;
}
}),
);
li.append(btn);
ul.append(li);
}
root.append(ul, errP);
}
// Ghost detection is entirely server-side: GET /api/permissions/stale
// returns the computed list of agent names that have explicit JSON entries
// but are absent from both the live roster and the kept-state tombstones.
// One call, no client-side roster cache, always authoritative.
async function fetchAndRenderStalePerms() {
const root = $("tombstones-stale-perms");
if (!root) return;
try {
const resp = await fetch("/api/permissions/stale");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderStalePerms(root, data.stale || []);
} catch (err) {
root.replaceChildren();
root.append(
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
);
}
stalePermsLoaded = true;
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() {
renderTombstones({ tombstones: tombstonesState });
@ -192,11 +106,6 @@ const SSE_HANDLERS = {
tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState });
},
// Refresh the stale-perms sub-section when perm data changes (a ghost
// was cleared, or perms were saved for an agent whose name collides).
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
};
// ─── boot ─────────────────────────────────────────────────────────────────
@ -223,16 +132,13 @@ async function init() {
bindAsyncForms(() => refreshState());
await refreshState();
fetchAndRenderStalePerms();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
// 13 wire kinds down to the 3 this page acts on. This page was one
// kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// to the kinds this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
"/api/dashboard/stream?kinds=tombstones_changed,tool_groups_changed",
);
const es = openStream("/api/dashboard/stream?kinds=tombstones_changed");
if (es) {
es.onmessage = (e) => {
let ev;

View file

@ -566,107 +566,6 @@ footer .banner-thin {
margin-bottom: 0.8em;
}
/* ─── tool-groups (permissions) table ──────────────────────────────
Agents × tool-groups matrix in the P3RM1SS10NS tab. Horizontally
scrollable on narrow viewports. */
.tg-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.tg-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.tg-table th,
.tg-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.tg-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.tg-agent-col {
text-align: left !important;
min-width: 8em;
}
.tg-group-col {
min-width: 5em;
}
.tg-agent-name {
color: var(--fg);
font-weight: 600;
}
.tg-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.tg-row:hover td {
background: var(--bg-elev);
}
/* ─── permissions save bar (save-all) ──────────────────────────────
One page-level save button for the whole P3RM1SS10NS tab. Sticks to
the bottom of the pane so it stays reachable while scrolling a long
agent×perm matrix. Disabled (dim) until an edit makes the page dirty;
the (N agents) count in the label reflects how many agents will
rebuild. `#perm-save-note` carries the transient rebuilding/error
line beside it. */
.perm-save-bar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: 0.8em;
margin-top: 1em;
padding: 0.7em 0;
background: var(--bg);
border-top: 1px solid var(--border);
}
.perm-save-bar .btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.perm-save-err {
color: var(--red);
}
/* Stale permission entry (agent not in the live roster) */
.perm-row-stale td {
opacity: 0.7;
}
.perm-stale-label {
font-size: 0.75em;
color: var(--muted);
margin-left: 0.4em;
}
.perm-remove-btn {
margin-left: 0.5em;
padding: 0.05em 0.5em;
font-size: 0.75em;
color: var(--red);
background: transparent;
border: 1px solid currentColor;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
opacity: 0.8;
}
.perm-remove-btn:hover {
opacity: 1;
}
.perm-remove-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Selection bar — sticky-bottom strip for bulk actions. */
.selection-bar {
position: fixed;

View file

@ -71,18 +71,6 @@
>
<span class="tab-label">◆ BUILD L0GS ◆</span>
</a>
<!-- P3RM1SS10NS: per-agent tool-group
assignments, fetched on tab activation. -->
<a
class="tab"
id="tab-permissions"
href="#permissions"
role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions"
>
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
@ -205,59 +193,6 @@
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<!-- P3RM1SS10NS: per-agent tool-group assignments, column-driven
from the backend (GET /api/tool-groups) so new groups require no
UI change. Edits stage in-browser; the page-level "save all"
button POSTs every changed agent to /api/permissions in one
atomic batch (one rebuild per agent). Capabilities are the
swarm's (`swarmctl agent permissions`), not set here. -->
<section
class="tab-pane"
id="tab-pane-permissions"
data-tab-pane="permissions"
hidden
role="tabpanel"
aria-labelledby="tab-permissions"
>
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
cols = tool groups fetched from GET /api/tool-groups.
Checking / unchecking is staged in the UI; the single page-level
"save all" button POSTs every changed agent to
/api/permissions in one atomic batch, one rebuild each.
Absent agents default to the role default (shown in parens). -->
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent tool-group permissions. columns are filled from the
backend — adding a new group requires no UI change. agents without
an explicit entry use the role default (agents: messaging, meta,
inbox, execution).
</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<!-- Page-level save bar (save-all issue): one button for the whole
permissions page. Staged checkbox edits are diffed against their
render-time baselines and POSTed together to /api/permissions, one
rebuild per agent. Disabled until something is dirty. The atomic
batch either fully lands (queued ✓ → rebuilding) or fully fails
(error note, nothing applied). -->
<div class="perm-save-bar">
<button
type="button"
id="perm-save-all"
class="btn btn-approve"
disabled
>
save all
</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the

View file

@ -41,9 +41,7 @@
<span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc"
>containers · builds · permissions</span
>
<span class="home-tile-desc">containers · builds</span>
</a>
<a class="home-tile" href="/flow.html">
@ -69,7 +67,7 @@
<span class="home-tile-icon" aria-hidden="true">⬡</span>
<span class="home-tile-label">Core</span>
</span>
<span class="home-tile-desc">kept state · stale permissions</span>
<span class="home-tile-desc">kept state</span>
</a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by

View file

@ -1,331 +0,0 @@
// Dashboard P3RM1SS10NS tab — the per-agent tool-groups matrix, with a
// page-level "save all" button. Capabilities are not set here: they are
// the swarm's (`swarmctl agent permissions`).
//
// The table is fetched on tab activation (`GET /api/tool-groups`) and
// after each save. Columns come from the backend so the UI needs no
// change when a new group is added. Live updates arrive via the
// `tool_groups_changed` dashboard event (fired after the rebuild-queue
// worker commits the perm JSON file), wired into the entry's mutation
// dispatch table.
//
// Stateless at module scope: each render builds fresh from the fetched
// payload. The agent roster (`containersState`) is the only shared state
// it reads — to union live containers with agents already named in the
// assignments map.
//
// Editing model: checkboxes are diffed against a `data-baseline` captured at
// render time; only changed agents are POSTed to `POST /api/permissions` as
// `{ changes: [{agent, tool_groups}] }` (a full replace per agent). Batch is
// atomic — saved→rebuilding only fires on a clean 200.
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { containersState } from "./state.js";
import { asyncBtn } from "@hive/shared/forms.js";
// ── SSE re-render guards ────────────────────────────────────────────
// Skip the live re-render when the operator has unsaved edits in that
// section (or a checkbox focused) — clobbering a half-finished edit set
// is worse than a brief staleness window. The tab-activation re-fetch
// and the post-save re-fetch are the recovery paths; both clear dirty.
export function applyToolGroupsChanged(ev) {
const root = $("tool-groups-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
renderToolGroups(root, ev);
}
// A section is dirty if any checkbox diverges from its render-time
// baseline. Cheap DOM scan; no module-level mirror to drift.
function sectionHasDirty(root) {
for (const cb of root.querySelectorAll("input[type=checkbox]")) {
if (cb.checked !== (cb.dataset.baseline === "1")) return true;
}
return false;
}
export async function fetchAndRenderToolGroups() {
const root = $("tool-groups-section");
if (!root) return;
root.replaceChildren();
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch("/api/tool-groups");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderToolGroups(root, data);
} catch (err) {
root.replaceChildren();
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
function renderToolGroups(root, data) {
root.replaceChildren();
const { groups, descriptions = {}, assignments, effective = {} } = data;
if (!groups || !groups.length) {
root.append(el("p", { class: "meta" }, "(no tool groups defined)"));
updateSaveBar();
return;
}
// Agent rows: prefer the backend roster (default-perms agents included);
// fall back to the live-container ∪ explicit union for older payloads.
const agentNames =
data.agents && data.agents.length
? [...data.agents]
: [
...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) {
root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar();
return;
}
const wrap = el("div", { class: "tg-table-wrap" });
const table = el("table", { class: "tg-table" });
// Header row.
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "tg-agent-col" }, "agent"));
for (const g of groups) {
hrow.append(
el("th", { class: "tg-group-col", title: descriptions[g] || g }, g),
);
}
thead.append(hrow);
table.append(thead);
const tbody = el("tbody");
for (const name of agentNames) {
// Effective groups (explicit-or-role-default) drive the checkboxes so
// a default agent shows its real groups, not blank — and saving keeps
// them instead of silently stripping the defaults. The "(default)"
// badge still keys off explicit-assignment presence.
const assigned = effective[name] || assignments[name] || [];
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
// `containersState` is keyed from `nixos-container list`, which
// includes stopped-but-configured containers — only destroyed/renamed
// agents are absent.
const isStale = !containersState.has(name);
const tr = el("tr", {
class: "tg-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el("td", { class: "tg-agent-col" });
nameTd.append(el("span", { class: "tg-agent-name" }, name));
if (isStale) {
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el(
"button",
{
type: "button",
class: "perm-remove-btn",
title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
} else if (!hasExplicit) {
nameTd.append(
el("span", { class: "meta tg-default-label" }, "(default)"),
);
}
tr.append(nameTd);
// One checkbox per group.
for (const g of groups) {
const checked = assigned.includes(g);
const td = el("td", { class: "tg-group-col" });
const cb = el("input", {
type: "checkbox",
class: "tg-cb",
"data-group": g,
"data-baseline": checked ? "1" : "0",
"aria-label": g,
});
cb.checked = checked;
cb.addEventListener("change", onCellToggle);
td.append(cb);
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
root.append(wrap);
updateSaveBar();
}
// ── Save-all wiring ─────────────────────────────────────────────────
// Recompute the save bar on every cell toggle. Also clears any stale
// error/✓ status the bar may be showing from a prior save.
function onCellToggle() {
clearSaveStatus();
updateSaveBar();
}
// Collect the sparse change-set: one entry per agent whose groups diverge
// from baseline, so untouched defaults stay defaults (an included array is
// a full replacement).
function collectChanges() {
const byAgent = new Map(); // agent -> { tool_groups }
collectSection(
$("tool-groups-section"),
".tg-row",
".tg-cb",
"group",
"tool_groups",
byAgent,
);
const changes = [];
for (const [agent, obj] of byAgent) changes.push({ agent, ...obj });
return changes;
}
function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
if (!root) return;
for (const tr of root.querySelectorAll(rowSel)) {
const agent = tr.dataset.agent;
if (!agent) continue;
let dirty = false;
const selected = [];
for (const cb of tr.querySelectorAll(cbSel)) {
if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true;
if (cb.checked) selected.push(cb.dataset[dataKey]);
}
if (dirty) {
const obj = byAgent.get(agent) || {};
obj[field] = selected;
byAgent.set(agent, obj);
}
}
}
function updateSaveBar() {
const btn = $("perm-save-all");
if (!btn) return;
// Don't stomp a transient saving/rebuilding label.
if (btn.dataset.busy === "1") return;
const n = collectChanges().length;
btn.disabled = n === 0;
btn.textContent =
n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`;
}
// Remove all explicit permission entries for a stale (non-running)
// agent. Calls DELETE /api/permissions/{agent}, which bypasses the
// roster guard so the stale entries can be cleaned up even though the
// agent isn't in the live container list. Re-fetches the table after
// the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot
? sectionRoot.querySelector(
`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`,
)
: null;
const doDelete = async () => {
try {
const resp = await fetch("/api/permissions/" + encodeURIComponent(name), {
method: "DELETE",
});
if (!resp.ok) {
const text = await resp.text().catch(() => resp.status);
setSaveNote("failed to remove " + name + ": " + text, true);
return;
}
// Re-fetch so the stale row disappears.
await fetchAndRenderToolGroups();
} catch (err) {
setSaveNote("failed to remove " + name + ": " + err, true);
}
};
// asyncBtn guards double-submit; fall through without guard when there
// is no button (e.g. called programmatically without a DOM context).
// Return the promise so callers can await clearStaleAgent() if needed.
if (btn) return asyncBtn(btn, doDelete);
await doDelete();
}
function clearSaveStatus() {
const note = $("perm-save-note");
if (note) {
note.textContent = "";
note.classList.remove("perm-save-err");
}
}
function setSaveNote(text, isErr) {
const note = $("perm-save-note");
if (!note) return;
note.textContent = text;
note.classList.toggle("perm-save-err", !!isErr);
}
async function saveAll() {
const btn = $("perm-save-all");
if (!btn) return;
const changes = collectChanges();
if (!changes.length) return;
btn.dataset.busy = "1";
btn.disabled = true;
btn.textContent = "saving…";
clearSaveStatus();
try {
const r = await fetch("/api/permissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!r.ok) {
const txt = await r.text();
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = "save all";
setSaveNote("save failed: " + (txt || "http " + r.status), true);
updateSaveBar();
return;
}
// Atomic 200: the whole batch landed. Show a rebuilding hint and
// re-fetch the table once the queue worker has committed — that
// resets baselines (dirty clears) and the bar disables itself.
const n = changes.length;
btn.textContent = "queued ✓";
setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false);
setTimeout(() => {
btn.dataset.busy = "";
fetchAndRenderToolGroups();
}, 900);
} catch (err) {
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = "save all";
setSaveNote("save failed: " + String(err), true);
updateSaveBar();
}
}
// Bind the page-level save button once. Called from the dashboard entry
// after the DOM is ready (the button lives in the static permissions
// pane markup, so it exists before any fetch).
export function initPermissions() {
const btn = $("perm-save-all");
if (btn && !btn.dataset.bound) {
btn.dataset.bound = "1";
btn.addEventListener("click", saveAll);
}
}

View file

@ -1,8 +1,8 @@
// Shared dashboard state — the one piece of cross-domain state in the
// dashboard SPA: the live agent roster.
//
// The roster is read by nearly every tab (the container tree, the
// tool-group matrix, and the selection bar), so
// The roster is read by nearly every tab (the container tree and the
// selection bar), so
// it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything
// else in the dashboard is domain-local and lives with its own module.

View file

@ -199,42 +199,3 @@
/* Remove the now-dead `.rqe-step` rule (the `step` sub-step label was
retired from the wire in `feat(job_queue): retire the now-off-wire step
sub-step label` — the rule has had no JS referents since then). */
/* ─── K3PT ST4T3 › stale permission entries sub-section ────────────────────
Agents with explicit tool-group entries in the JSON but no live
container (renamed or manually-deleted agents). Lazy-loaded on tab
activation, refreshed on tool_groups_changed. */
.tombstones-stale-heading {
margin: 1.2em 0 0.3em;
color: var(--muted);
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.08em;
border-top: 1px solid var(--border);
padding-top: 0.8em;
}
.tombstones-stale-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.3em;
}
.tombstones-stale-row {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.35em 0.6em;
border: 1px dashed var(--border);
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
}
.tombstones-stale-name {
color: var(--muted);
font-weight: bold;
font-size: 0.9em;
}
.tombstones-stale-err {
margin-top: 0.4em;
color: var(--red);
font-size: 0.85em;
}

View file

@ -16,11 +16,6 @@ import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js";
import {
applyToolGroupsChanged,
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import {
initJobqRollup,
syncTransientsFromSnapshot,
@ -59,11 +54,7 @@ window.marked = marked;
// Sections whose innerHTML gets blown away on each refresh. If the
// operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
"containers-section",
"inbox-section",
"tool-groups-section",
];
const MANAGED_SECTION_IDS = ["containers-section", "inbox-section"];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails
@ -207,7 +198,6 @@ window.marked = marked;
},
meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning,
tool_groups_changed: applyToolGroupsChanged,
};
(function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the
@ -218,13 +208,12 @@ window.marked = marked;
// fallback when SharedWorker isn't supported.
//
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
// only the 9 wire kinds this page acts on.
// only the 7 wire kinds this page acts on.
const es = openStream(
"/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running," +
"tool_groups_changed",
"meta_inputs_changed,meta_update_running",
);
es.onmessage = (e) => {
let ev;
@ -256,8 +245,8 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` /
// `#permissions` activate the matching pane on the dashboard. Empty
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs`
// activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only
@ -274,14 +263,6 @@ window.marked = marked;
// visibility (bar only belongs on 4G3NTS where agent cards live).
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Permissions table: SSE covers worker-applied changes
// (tool_groups_changed); re-fetch on
// activation as a safety net for any gap between SSE events and
// the cold-load snapshot.
if (target === "permissions") {
initPermissions();
fetchAndRenderToolGroups();
}
onBuildsTabShown(target);
}

View file

@ -25,6 +25,9 @@
// Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why.
//
// Permissions: checkboxes from `GET /api/permissions`, on the default set;
// sent only when changed, so an untouched agent follows the default.
import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -44,6 +47,12 @@ import {
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -59,6 +68,12 @@ interface CreateAgentResponse {
node_id: number;
}
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
@ -103,6 +118,12 @@ export function CreateAgentForm({
EMPTY_INFERENCE_DRAFT,
);
const inferenceProblem = draftProblem(inference);
// `null` until `/api/permissions` answers; `selected` starts on its default.
const [catalogue, setCatalogue] = useState<PermissionCatalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null,
);
const [selected, setSelected] = useState<string[]>([]);
// Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one
@ -124,6 +145,19 @@ export function CreateAgentForm({
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data);
setSelected(data.default);
})().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
}, []);
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name,
label: h.name,
@ -146,6 +180,9 @@ export function CreateAgentForm({
hive,
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
}),
});
if (!r.ok) {
@ -155,6 +192,7 @@ export function CreateAgentForm({
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id });
setName("");
if (catalogue) setSelected(catalogue.default);
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
@ -176,6 +214,12 @@ export function CreateAgentForm({
problem={hivesError}
/>
)}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
@ -202,6 +246,19 @@ export function CreateAgentForm({
: "select a hive"
}
/>
<FormField label="permissions" htmlFor="agent-permissions">
<div id="agent-permissions">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
/>
) : (
<span>loading…</span>
)}
</div>
</FormField>
<details class="create-agent-section">
<summary>resource caps</summary>
<div class="create-agent-section-body">
@ -230,6 +287,7 @@ export function CreateAgentForm({
disabled={
result.status === "submitting" ||
!hivesReady ||
!catalogue ||
inferenceProblem !== null
}
>

View file

@ -0,0 +1,24 @@
// The create-agent form's permissions logic, kept out of the component so
// `npm test` can run it.
/** `selected` with `value` added, or removed when it is there. */
export function togglePermission(selected: string[], value: string): string[] {
return selected.includes(value)
? selected.filter((p) => p !== value)
: [...selected, value];
}
/**
* The `permissions` to send with a create: `undefined` while `selected` is
* the default set (in any order), so the agent's key stays unset and follows
* the default; otherwise the list, which may be empty.
*/
export function requestPermissions(
selected: string[],
defaults: string[],
): string[] | undefined {
const same =
selected.length === defaults.length &&
selected.every((p) => defaults.includes(p));
return same ? undefined : selected;
}

View file

@ -0,0 +1,31 @@
// The create-agent form's permissions field: what a toggle does and what the
// create request carries. Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
requestPermissions,
togglePermission,
} from "../src/pages/CreateAgentPermissions.ts";
const defaults = ["messaging", "meta", "inbox", "execution"];
test("a toggle adds a missing permission and removes a present one", () => {
assert.deepEqual(togglePermission(["meta"], "inbox"), ["meta", "inbox"]);
assert.deepEqual(togglePermission(["meta", "inbox"], "meta"), ["inbox"]);
});
test("the default set, in any order, sends no permissions", () => {
assert.equal(
requestPermissions([...defaults].reverse(), defaults),
undefined,
);
});
test("a changed set is sent, an empty one included", () => {
assert.deepEqual(requestPermissions(["messaging"], defaults), ["messaging"]);
assert.deepEqual(requestPermissions([], defaults), []);
assert.deepEqual(requestPermissions([...defaults, "web_tools"], defaults), [
...defaults,
"web_tools",
]);
});

View file

@ -11,7 +11,7 @@
//!
//! One `AgentServer { socket }` struct for all roles.
//! Tool access is gated upstream by `--allowedTools` (derived from the
//! agent's `ToolGroup` config); the server itself is a dumb dispatcher.
//! agent's `Permission` set); the server itself is a dumb dispatcher.
//! All tools go through the same `run_tool_envelope` helper.
use std::future::Future;
@ -135,7 +135,7 @@ impl AgentServer {
}
// IMPORTANT: when adding a new `#[tool]` fn to this impl, also add
// its name to the matching `ToolGroup::tools()` slice in hive-sh4re.
// its name to the matching `Permission::tools()` slice in hive-sh4re.
// Claude Code's permission gate refuses uninlisted MCP tools in
// non-interactive `--print` mode with "permissions not granted yet".
#[tool_router]

View file

@ -44,6 +44,7 @@ serde_json.workspace = true
swarm-queue-client = { workspace = true, features = [
"agent-schedules",
"agent-inference",
"agent-permissions",
] }
# This agent's own queue secret, read from the store under its own certificate.
swarm-secret-client.workspace = true

View file

@ -30,7 +30,7 @@ map of the module tree.
- **`login.rs` / `login_session.rs`** — first-run and session-resume
auth flow for the `claude` CLI.
- **`mcp_config.rs`** — renders the per-turn `--mcp-config` /
`--allowedTools` blob from tool groups + capabilities.
`--allowedTools` blob from the agent's permissions.
- **`todos.rs` / `reminders.rs` / `todo_server.rs`** — the harness-local
loose-ends v2 stores (sqlite-backed) and the in-agent socket server
extra MCP daemons + `hive-agent-mcp` dial into for todo/reminder ops.

View file

@ -36,6 +36,7 @@ mod swarm_agent_icon;
mod swarm_agent_state;
mod swarm_inference;
mod swarm_input;
mod swarm_permissions;
mod swarm_queue;
mod swarm_term;
mod term_msg;
@ -496,6 +497,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
swarm_agent_icon::spawn();
// The model and effort the swarm sets for this agent, over its local ones.
tokio::spawn(swarm_inference::run(bus.clone()));
// The permissions the swarm grants this agent, read before each turn.
tokio::spawn(swarm_permissions::run());
// Set by the web UI's `/api/cancel` on a successful SIGINT, read-and-
// cleared by `handle_turn` before building the next wake prompt — see
// `hive_sh4re::inbox::INTERRUPTED_HINT`. Shared between the web server task and

View file

@ -1,4 +1,4 @@
//! Claude launch-config layer: resolves the agent's tool-group / capability
//! Claude launch-config layer: resolves the agent's permission
//! set into the `--allowedTools` / `--tools` argument strings and renders the
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
//! any `services.hyperhive.agent.extraMcpServers`). Pure config-string generation consumed by
@ -6,6 +6,8 @@
//! running MCP server (a separate binary) — the `send` allow-list check that
//! server enforces lives alongside it in the `hive-agent-mcp` crate.
use hive_sh4re::permissions::Permission;
/// Name of the hyperhive MCP server inside claude's view. Claude prefixes
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
pub const SERVER_NAME: &str = "hyperhive";
@ -18,87 +20,83 @@ pub const SERVER_NAME: &str = "hyperhive";
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
/// The built-in tool surface — the base list, the group-gated additions, the
/// `HIVE_TOOL_GROUPS` parse, and the `--tools` value they resolve to — lives
/// in `hive_sh4re::permissions`, next to `ToolGroup` itself. Re-exported here
/// because this module is where the rest of the claude launch config is
/// assembled, and because the subagent daemon (`hive-subagent-mcp`) spawns
/// its own `claude` from the same base resolution (plus its own `Bash`
/// exception — see `hive_sh4re::permissions::subagent_builtin_tools_arg`):
/// `hive-agent` is a binary-only crate with no lib target, so a shared home
/// was the only way for both spawners to read one list rather than two that
/// drift.
pub use hive_sh4re::permissions::{builtin_tools_arg, effective_tool_groups};
/// The value for claude's `--tools` flag this turn: which built-in tools exist
/// in the session at all, from the agent's current permissions. The
/// resolution lives in `hive_sh4re::permissions` because the subagent daemon
/// (`hive-subagent-mcp`) spawns its own `claude` from the same one.
#[must_use]
pub fn builtin_tools_arg() -> String {
hive_sh4re::permissions::builtin_tools_for(&crate::swarm_permissions::current()).join(",")
}
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
/// Permission an extra (out-of-process) MCP server is gated behind, if any.
///
/// Most `services.hyperhive.agent.extraMcpServers` entries are ungated — available whenever
/// the operator declares them. The `bash` server is the exception: raw shell
/// execution is a privilege, so it is only exposed when the agent holds the
/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at
/// dispatch) and the capability tools (re-checked server-side by hive-c0re),
/// execution is a privilege, so it is only exposed when the agent holds
/// `Execution`. Unlike the in-process hyperhive tools (gated at dispatch),
/// an out-of-process server has **no** later enforcement point — once it is
/// in the claude MCP config the agent can call it. So this gate, applied at
/// config-render time, is the security boundary for those servers.
fn extra_server_required_group(server: &str) -> Option<hive_sh4re::permissions::ToolGroup> {
fn extra_server_required_permission(server: &str) -> Option<Permission> {
match server {
"bash" => Some(hive_sh4re::permissions::ToolGroup::Execution),
"bash" => Some(Permission::Execution),
_ => None,
}
}
/// Whether an extra MCP server should be exposed to claude given the active
/// tool `groups`. A gated server (see [`extra_server_required_group`]) is
/// suppressed when the agent lacks its required group.
fn extra_server_enabled(server: &str, groups: &[hive_sh4re::permissions::ToolGroup]) -> bool {
extra_server_required_group(server).is_none_or(|required| groups.contains(&required))
/// Whether an extra MCP server should be exposed to claude given the agent's
/// `perms`. A gated server (see [`extra_server_required_permission`]) is
/// suppressed when the agent lacks its required permission.
fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool {
extra_server_required_permission(server).is_none_or(|required| perms.contains(&required))
}
#[cfg(test)]
mod extra_server_gate_tests {
use super::{extra_server_enabled, extra_server_required_group};
use hive_sh4re::permissions::ToolGroup;
use super::{extra_server_enabled, extra_server_required_permission};
use hive_sh4re::permissions::Permission;
#[test]
fn bash_is_gated_behind_execution() {
assert_eq!(
extra_server_required_group("bash"),
Some(ToolGroup::Execution)
extra_server_required_permission("bash"),
Some(Permission::Execution)
);
// Suppressed without Execution, even if other groups are present.
assert!(!extra_server_enabled(
"bash",
&[ToolGroup::Messaging, ToolGroup::Inbox]
&[Permission::Messaging, Permission::Inbox]
));
// Available once Execution is granted.
assert!(extra_server_enabled("bash", &[ToolGroup::Execution]));
assert!(extra_server_enabled("bash", &[Permission::Execution]));
}
#[test]
fn other_servers_are_ungated() {
assert_eq!(extra_server_required_group("matrix"), None);
assert_eq!(extra_server_required_group("scraper"), None);
assert_eq!(extra_server_required_permission("matrix"), None);
assert_eq!(extra_server_required_permission("scraper"), None);
// An ungated server is available regardless of (even empty) groups.
assert!(extra_server_enabled("matrix", &[]));
assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging]));
assert!(extra_server_enabled("scraper", &[Permission::Messaging]));
}
}
/// MCP tools claude is allowed to call without prompting, derived from
/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re
/// (single source of truth). See `docs/process/conventions.md::Tool groups`.
/// the supplied permissions. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `Permission::tools()` slice in hive-sh4re
/// (single source of truth).
#[must_use]
pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<String> {
pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec<String> {
// Collect all tool names, deduplicating while preserving order.
// Always-on tools (e.g. `set_status`) come first so they're present
// regardless of which groups the agent is granted — a misconfigured
// regardless of which permissions the agent holds — a misconfigured
// agent still has to be able to report its dashboard status.
let mut seen = std::collections::HashSet::new();
let mut out: Vec<String> = hive_sh4re::permissions::ToolGroup::ALWAYS_ON_TOOLS
let mut out: Vec<String> = Permission::ALWAYS_ON_TOOLS
.iter()
.copied()
.chain(groups.iter().flat_map(|g| g.tools().iter().copied()))
.chain(perms.iter().flat_map(|p| p.tools().iter().copied()))
.filter(|t| seen.insert(*t))
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
.collect();
@ -108,7 +106,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
// them without per-tool operator approval. `["*"]` (the default)
// expands to `mcp__<server>__*` — every tool from that server.
for (server, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
if server == SERVER_NAME || !extra_server_enabled(&server, perms) {
continue;
}
for pat in spec.allowed_tools() {
@ -122,14 +120,14 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
/// both the built-ins and the MCP surface.
#[must_use]
pub fn allowed_tools_arg() -> String {
let groups = effective_tool_groups();
let perms = crate::swarm_permissions::current();
// The same built-ins `--tools` makes exist this session, so a tool that
// exists is never one claude has to prompt about.
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&groups)
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&perms)
.into_iter()
.map(ToOwned::to_owned)
.collect();
all.extend(allowed_mcp_tools(&groups));
all.extend(allowed_mcp_tools(&perms));
all.join(",")
}
@ -148,7 +146,7 @@ pub fn render_claude_config() -> String {
/// The set of MCP server names claude is configured with this turn — the
/// keys of the rendered `--mcp-config` (built-in hyperhive HTTP surface +
/// any tool-group-permitted extra servers). The harness compares
/// any permitted extra servers). The harness compares
/// this against the per-turn `system`/`init` event's `mcp_servers` to
/// detect a configured server that failed to connect or was dropped
/// (the MCP-health instrumentation).
@ -158,7 +156,7 @@ pub fn configured_server_names() -> Vec<String> {
}
/// Build the `mcpServers` map claude gets in its `--mcp-config`: the
/// built-in hyperhive HTTP surface plus any tool-group-permitted extra
/// built-in hyperhive HTTP surface plus any permitted extra
/// servers (stdio or http, per each entry's `type`). Shared by
/// [`render_claude_config`] (serialises it) and [`configured_server_names`]
/// (lists its keys) so the two never drift.
@ -187,11 +185,11 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
// (Http entries have no child-process env to inject into — the daemon
// behind the URL resolves its own state dir independently.)
let state_dir = crate::paths::state_dir();
// Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`).
// Gate permission-restricted extra servers (e.g. `bash` → `Execution`).
// This is the security boundary for them: an out-of-process server the
// agent isn't entitled to must not even appear in the MCP config, or the
// agent could call it directly (there is no later enforcement point).
let groups = effective_tool_groups();
let perms = crate::swarm_permissions::current();
for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if name == SERVER_NAME {
tracing::warn!(
@ -199,10 +197,10 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
);
continue;
}
if !extra_server_enabled(&name, &groups) {
if !extra_server_enabled(&name, &perms) {
tracing::info!(
server = %name,
"extra MCP server suppressed: agent lacks the required tool group"
"extra MCP server suppressed: agent lacks the required permission"
);
continue;
}
@ -214,7 +212,7 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
#[cfg(test)]
mod tests {
use super::{SERVER_NAME, allowed_mcp_tools};
use hive_sh4re::permissions::ToolGroup;
use hive_sh4re::permissions::Permission;
fn qualified(tool: &str) -> String {
format!("mcp__{SERVER_NAME}__{tool}")
@ -222,7 +220,7 @@ mod tests {
#[test]
fn set_status_present_with_no_groups() {
// An agent with zero tool groups (or any group set that omits
// An agent with no permissions (or any set that omits
// `meta`) must still be able to report its dashboard status.
let tools = allowed_mcp_tools(&[]);
assert!(
@ -234,7 +232,7 @@ mod tests {
#[test]
fn mark_todos_done_present_with_no_groups() {
// Regression: mark_todos_done was declared as a
// tool but never added to any ToolGroup, so no agent could ever
// tool but never added to any group, so no agent could ever
// get it into --allowedTools regardless of which groups it held —
// including `inbox`, which only gates get_loose_ends/
// cancel_loose_end/remind.
@ -243,13 +241,13 @@ mod tests {
tools.contains(&qualified("mark_todos_done")),
"mark_todos_done missing from empty-group allow-list: {tools:?}"
);
let tools = allowed_mcp_tools(&[ToolGroup::Inbox]);
let tools = allowed_mcp_tools(&[Permission::Inbox]);
assert!(tools.contains(&qualified("mark_todos_done")));
}
#[test]
fn set_status_present_without_meta_group() {
let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]);
let tools = allowed_mcp_tools(&[Permission::Messaging, Permission::Inbox]);
assert!(tools.contains(&qualified("set_status")));
// get_agent_meta stays gated behind `meta` — only set_status is always-on.
assert!(!tools.contains(&qualified("get_agent_meta")));
@ -257,7 +255,7 @@ mod tests {
#[test]
fn no_duplicate_set_status_when_meta_granted() {
let tools = allowed_mcp_tools(&[ToolGroup::Meta]);
let tools = allowed_mcp_tools(&[Permission::Meta]);
let count = tools
.iter()
.filter(|t| **t == qualified("set_status"))

View file

@ -0,0 +1,109 @@
//! The permissions the swarm grants this agent.
//!
//! swarm-controller keeps the agent's set under its key in the
//! `agent-permissions` KV bucket. This task reads that key every
//! [`POLL_INTERVAL`] with the one subject the agent's own queue credential is
//! granted there, and [`current`] answers with the last value read. The turn
//! loop asks [`current`] for every turn's `--tools`, `--allowedTools` and MCP
//! config, so a change reaches the next turn.
//!
//! Until the first successful read after a start, and for an unset key, the
//! agent holds [`Permission::default_for`]. A failed read keeps the last
//! value.
use std::sync::{PoisonError, RwLock};
use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{AgentPermissions, Read, read};
/// How often the key is read. Also the longest a change can take to reach
/// the agent's next turn.
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// The last value read; `None` until the first read, which is the default.
static CURRENT: RwLock<Option<Vec<Permission>>> = RwLock::new(None);
/// The permissions this agent holds now.
#[must_use]
pub fn current() -> Vec<Permission> {
CURRENT
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
.unwrap_or_else(|| Permission::default_for(&crate::identity::label()))
}
/// What one read of the key leaves the agent holding: the swarm's list when
/// it sets one, the default when the key is unset, and `None` (keep the last
/// value) when the read failed or the bucket did not answer.
fn after(agent: &str, read: Option<Read>) -> Option<Vec<Permission>> {
match read? {
Read::Current(AgentPermissions { permissions }) => {
Some(permissions.unwrap_or_else(|| Permission::default_for(agent)))
}
Read::NoBucket => None,
}
}
/// Run the poller for the harness's lifetime, if this agent has a label to
/// name its key with.
pub async fn run() {
let agent = crate::identity::label();
if agent.is_empty() {
tracing::warn!("this agent has no label; not reading its swarm permissions");
return;
}
let client = crate::swarm_queue::client().await;
tracing::info!(%agent, "reading swarm permissions from the swarm queue");
loop {
if let Some(perms) = after(&agent, read_once(&client, &agent).await) {
let mut current = CURRENT.write().unwrap_or_else(PoisonError::into_inner);
if current.as_ref() != Some(&perms) {
tracing::info!(?perms, "permissions: the swarm's set changed");
*current = Some(perms);
}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// The key's current value, or `None` when it could not be read.
async fn read_once(client: &async_nats::Client, agent: &str) -> Option<Read> {
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client).ok()?;
match read(client, agent).await {
Ok(read) => Some(read),
Err(e) => {
tracing::warn!(error = %e, "permissions: reading this agent's key failed");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(json: &str) -> Read {
Read::Current(serde_json::from_str(json).expect("decodes"))
}
#[test]
fn a_read_sets_the_list_or_the_default_and_a_failure_keeps_the_last() {
assert_eq!(
after("atlas", Some(record(r#"{"permissions":["web_tools"]}"#))),
Some(vec![Permission::WebTools])
);
assert_eq!(
after("atlas", Some(record(r#"{"permissions":[]}"#))),
Some(Vec::new())
);
assert_eq!(
after("atlas", Some(record("{}"))),
Some(Permission::DEFAULT.to_vec())
);
assert_eq!(after("atlas", Some(Read::NoBucket)), None);
assert_eq!(after("atlas", None), None);
}
}

View file

@ -91,10 +91,12 @@ and end the turn.";
/// prompt (`--system-prompt-file`). Static settings are no longer
/// passed here — they live at `/etc/claude-code/managed-settings.json`
/// and claude auto-discovers them.
/// Materialised once at harness startup; shared between the turn loop
/// and the operator-driven `/compact` path so both invocations look
/// identical to claude (same MCP surface, same allowed tools, same
/// role prompt — only the stdin payload differs).
/// Materialised at harness startup; shared between the turn loop and the
/// operator-driven `/compact` path so both invocations look identical to
/// claude (same MCP surface, same allowed tools, same role prompt — only the
/// stdin payload differs). The MCP config is rewritten before each
/// invocation ([`TurnFiles::refresh_mcp_config`]), since which servers it
/// holds follows the agent's current permissions.
#[derive(Clone)]
pub struct TurnFiles {
pub mcp_config: PathBuf,
@ -114,6 +116,16 @@ impl TurnFiles {
system_prompt: write_system_prompt(socket, label).await?,
})
}
/// Rewrite the MCP config from the agent's current permissions, so a
/// change to `execution` reaches this invocation's `bash` server. A
/// failed write is logged and the previous file stays.
pub async fn refresh_mcp_config(&self) {
if let Err(e) = tokio::fs::write(&self.mcp_config, mcp_config::render_claude_config()).await
{
tracing::warn!(path = %self.mcp_config.display(), error = %e, "rewriting the claude MCP config failed");
}
}
}
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
@ -335,22 +347,25 @@ pub fn make_session(bus: &Bus) -> Result<AgentSession> {
})
}
/// The answer to an ACP agent's permission requests, see [`acp_permits`].
/// The answer to an ACP agent's permission requests, see [`acp_permits`],
/// against the agent's permissions at the moment it asks.
fn acp_permission_policy() -> PermissionPolicy {
let web =
mcp_config::effective_tool_groups().contains(&hive_sh4re::permissions::ToolGroup::WebTools);
std::sync::Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, web))
std::sync::Arc::new(|ask: &PermissionAsk<'_>| {
let web = crate::swarm_permissions::current()
.contains(&hive_sh4re::permissions::Permission::WebTools);
acp_permits(ask, web)
})
}
/// Whether an ACP agent may run the tool call it asks about. Default-deny,
/// allowing what a claude agent has:
///
/// - `other`-kind calls to tools of the MCP servers the session was handed
/// — which of those an agent gets is already decided by its tool groups
/// — which of those an agent gets is already decided by its permissions
/// (`mcp_config`);
/// - the file tools, mirroring the claude built-ins
/// (`hive_sh4re::permissions`): `read`, `edit`, `search`;
/// - `fetch` with the `web_tools` group (`web`).
/// - `fetch` with the `web_tools` permission (`web`).
///
/// Everything else is refused, including a built-in shell (`execute`) —
/// shell goes through `mcp__bash__run` — and any kind this list doesn't name.
@ -395,6 +410,7 @@ pub async fn drive_turn(
// Heuristic: context large AND prompt cache gone cold.
maybe_auto_reset(bus, session);
}
files.refresh_mcp_config().await;
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
let mut result = session.run(&config, prompt, &sink).await;
@ -621,6 +637,7 @@ pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSe
text: "operator: /compact — running on idle session".into(),
});
bus.set_state(crate::events::TurnState::Compacting);
files.refresh_mcp_config().await;
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
report_compact(bus, session.compact(&config, &sink).await);

View file

@ -1,17 +1,16 @@
//! Per-agent configuration registries: tool groups, resource limits,
//! topology (JSON files under `/var/lib/hyperhive/meta/`), the capabilities
//! the swarm grants (outside the meta repo) and the shared wire-protocol size
//! limits. Each submodule is re-exported at the crate root, so
//! `crate::topology::…` etc. keep working unchanged.
//! Per-agent configuration registries: resource limits, topology (JSON files
//! under `/var/lib/hyperhive/meta/`), the permissions the swarm grants
//! (outside the meta repo) and the shared wire-protocol size limits. Each
//! submodule is re-exported at the crate root, so `crate::topology::…` etc.
//! keep working unchanged.
//!
//! Note the two similarly-named modules: [`limits`] caps inline
//! *message body* sizes on the sockets, while [`resource_limits`] holds
//! per-agent CPU/memory caps for the container drop-in.
pub mod capabilities;
pub mod limits;
pub mod permissions;
pub mod resource_limits;
pub mod tool_groups;
pub mod topology;
use std::io::{self, Write as _};

View file

@ -1,20 +1,22 @@
//! The capabilities the swarm grants this hive's agents, as last read from the
//! The permissions the swarm grants this hive's agents, as last read from the
//! `agent-permissions` bucket by `workers::swarm_permissions`. Stored at
//! `/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo:
//! the swarm is the record, this file is the hive's last read of it.
//!
//! Format: agent name → the stored
//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with
//! no entry, or an entry with `capabilities` unset, holds its default
//! ([`default_for`]); `"capabilities": []` holds none.
//! no entry, or an entry with `permissions` unset, holds
//! [`Permission::default_for`]; `"permissions": []` holds none.
//!
//! Read path: `lifecycle::host_config::set_nspawn_flags`, on every container
//! start, which is why a change takes a restart.
//! hive-c0re enforces one member, [`Permission::ManageRootAgent`]: read by
//! `lifecycle::host_config::set_nspawn_flags` on every container start, which
//! is why a change to it takes a restart. The agent reads the tool members
//! itself.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::AgentPermissions;
const FILE: &str = "swarm-agent-permissions.json";
@ -37,32 +39,21 @@ pub fn write_at(path: &Path, stored: &Stored) -> std::io::Result<()> {
super::write_map(path, stored)
}
/// The capabilities an agent holds when the swarm sets none: `ruth` recovers
/// the other agents, every other agent holds nothing.
/// The permissions `agent` holds under `record`.
#[must_use]
pub fn default_for(agent: &str) -> Vec<Capability> {
if agent == crate::lifecycle::MANAGER_NAME {
vec![Capability::ManageRootAgent]
} else {
Vec::new()
}
}
/// The capabilities `agent` holds under `record`.
#[must_use]
pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Capability> {
pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Permission> {
record
.and_then(|r| r.capabilities.clone())
.unwrap_or_else(|| default_for(agent))
.and_then(|r| r.permissions.clone())
.unwrap_or_else(|| Permission::default_for(agent))
}
/// Whether `agent` holds `cap` per the file at `path` (normally [`path`]).
/// Whether `agent` holds `perm` per the file at `path` (normally [`path`]).
///
/// # Errors
///
/// As [`read_at`].
pub fn holds(path: &Path, agent: &str, cap: Capability) -> std::io::Result<bool> {
Ok(effective(agent, read_at(path)?.get(agent)).contains(&cap))
pub fn holds(path: &Path, agent: &str, perm: Permission) -> std::io::Result<bool> {
Ok(effective(agent, read_at(path)?.get(agent)).contains(&perm))
}
#[cfg(test)]
@ -75,19 +66,16 @@ mod tests {
#[test]
fn unset_is_the_default_and_an_empty_list_is_none() {
assert_eq!(effective("ruth", None), vec![Capability::ManageRootAgent]);
assert_eq!(
effective("ruth", Some(&record("{}"))),
vec![Capability::ManageRootAgent]
);
assert!(effective("ruth", Some(&record(r#"{"capabilities":[]}"#))).is_empty());
assert!(effective("atlas", None).is_empty());
assert!(effective("ruth", None).contains(&Permission::ManageRootAgent));
assert!(effective("ruth", Some(&record("{}"))).contains(&Permission::ManageRootAgent));
assert!(effective("ruth", Some(&record(r#"{"permissions":[]}"#))).is_empty());
assert_eq!(effective("atlas", None), Permission::DEFAULT.to_vec());
assert_eq!(
effective(
"atlas",
Some(&record(r#"{"capabilities":["manage_root_agent"]}"#))
Some(&record(r#"{"permissions":["manage_root_agent"]}"#))
),
vec![Capability::ManageRootAgent]
vec![Permission::ManageRootAgent]
);
}
@ -95,15 +83,15 @@ mod tests {
fn a_missing_file_holds_the_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
assert!(holds(&path, "ruth", Capability::ManageRootAgent).expect("missing is empty"));
assert!(!holds(&path, "atlas", Capability::ManageRootAgent).expect("missing is empty"));
assert!(holds(&path, "ruth", Permission::ManageRootAgent).expect("missing is empty"));
assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("missing is empty"));
}
#[test]
fn a_corrupt_file_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
std::fs::write(&path, "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag").expect("seed");
holds(&path, "ruth", Capability::ManageRootAgent).expect_err("truncated JSON");
std::fs::write(&path, "{\n \"ruth\": {\"permissions\": [\"manage_root_ag").expect("seed");
holds(&path, "ruth", Permission::ManageRootAgent).expect_err("truncated JSON");
}
}

View file

@ -3,7 +3,7 @@
//!
//! The swarm caps are stored at
//! `/var/lib/hyperhive/meta/swarm-resource-limits.json` alongside
//! `topology.json` and `tool-groups.json`.
//! `topology.json`.
//!
//! Format: a JSON object mapping agent name to an object with optional
//! `cpu_quota` / `memory_max` strings, passed verbatim to systemd's

View file

@ -1,245 +0,0 @@
//! Per-agent tool-group configuration. Stored at
//! `/var/lib/hyperhive/meta/tool-groups.json` alongside `topology.json`
//! and the meta `flake.nix`.
//!
//! Format: a JSON object mapping agent name to an array of
//! `hive_sh4re::permissions::ToolGroup` `snake_case` strings:
//!
//! ```json
//! {
//! "alice": ["messaging", "meta", "inbox", "lifecycle"],
//! "bob": ["messaging", "meta", "inbox"]
//! }
//! ```
//!
//! An absent entry (or an absent file) means "use the harness default"
//! (`AGENT_DEFAULT`: `messaging + meta + inbox + execution`). `render_flake`
//! in `meta.rs` reads this file and injects `HIVE_TOOL_GROUPS` into each
//! agent's systemd service env; agents with no entry get no env var and the
//! harness falls back to `AGENT_DEFAULT`.
//!
//! Write path: `set_groups` is called from the dashboard action handler
//! that the operator uses to grant/revoke tool groups per agent.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
const TOOL_GROUPS_FILE: &str = "tool-groups.json";
#[must_use]
pub fn tool_groups_path() -> PathBuf {
crate::paths::meta_root().join(TOOL_GROUPS_FILE)
}
/// Read the per-agent tool-group map. An absent file is the empty map —
/// callers treat a missing entry as "use role default". A file that
/// exists but can't be read or parsed is an error. Any entry that isn't a
/// recognised [`hive_sh4re::permissions::ToolGroup`] is dropped (with a
/// `warn!`) from what's returned — a stale or renamed name is never
/// honoured — but `read` itself never writes; unknown names stay on disk
/// until the next `set_groups` rewrites the file.
pub fn read() -> std::io::Result<BTreeMap<String, Vec<String>>> {
read_from(&tool_groups_path())
}
fn read_from(path: &Path) -> std::io::Result<BTreeMap<String, Vec<String>>> {
let mut map = super::read_map(path)?;
prune_unknown(&mut map);
Ok(map)
}
/// True if `name` matches a recognised [`hive_sh4re::permissions::ToolGroup`],
/// case-insensitive (same comparison [`validate_groups`] uses, and the same
/// comparison `capabilities::is_known` uses — a hand-edited `"Meta"` in the
/// file should round-trip, not get pruned).
fn is_known(name: &str) -> bool {
hive_sh4re::permissions::ToolGroup::ALL
.iter()
.any(|g| name.eq_ignore_ascii_case(<&str>::from(*g)))
}
/// Drop any tool-group string in `map` that isn't a recognised
/// [`hive_sh4re::permissions::ToolGroup`], warning per dropped string so a
/// stale or renamed grant doesn't look like it's still in effect. An agent
/// left with no groups is removed entirely, matching [`set_groups`]'s
/// empty-vec behaviour (reverts to the harness role default). Returns
/// whether anything was dropped.
fn prune_unknown(map: &mut BTreeMap<String, Vec<String>>) -> bool {
let mut dropped_any = false;
map.retain(|agent, groups| {
groups.retain(|g| {
let known = is_known(g);
if !known {
dropped_any = true;
tracing::warn!(agent, tool_group = %g, "dropping unrecognised tool group");
}
known
});
!groups.is_empty()
});
dropped_any
}
/// Look up the configured tool groups for one agent. Returns an empty
/// vec when the agent has no entry — callers should treat this as
/// "use the harness role default." Errors as [`read`] does.
pub fn groups_for(name: &str) -> std::io::Result<Vec<String>> {
Ok(read()?.get(name).cloned().unwrap_or_default())
}
/// Validate a slice of group name strings against `ToolGroup::ALL`.
/// Returns `Ok(())` when all names are known, or `Err` listing the
/// unrecognised names so callers can surface a useful error message.
pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> {
let unknown: Vec<&str> = groups
.iter()
.map(String::as_str)
.filter(|s| !is_known(s))
.collect();
if unknown.is_empty() {
Ok(())
} else {
anyhow::bail!(
"unknown tool group(s): {}; valid names are: {}",
unknown.join(", "),
hive_sh4re::permissions::ToolGroup::ALL
.iter()
.map(|g| <&str>::from(*g))
.collect::<Vec<_>>()
.join(", ")
)
}
}
/// Set the tool groups for one agent and persist the map. An empty
/// `groups` vec removes the entry (agent reverts to role default).
/// Returns an error if any name is not in `ToolGroup::ALL`, or if the
/// existing file can't be read or parsed — the file is then left
/// untouched.
pub fn set_groups(name: &str, groups: &[String]) -> anyhow::Result<()> {
set_groups_at(&tool_groups_path(), name, groups)
}
fn set_groups_at(path: &Path, name: &str, groups: &[String]) -> anyhow::Result<()> {
if !groups.is_empty() {
validate_groups(groups)?;
}
let mut current: BTreeMap<String, Vec<String>> = super::read_map(path)?;
if groups.is_empty() {
current.remove(name);
} else {
current.insert(name.to_owned(), groups.to_vec());
}
super::write_map(path, &current).with_context(|| format!("write tool-groups for {name}"))
}
/// Drop the entry for an agent that is being destroyed. Idempotent.
/// Fails without writing if the existing file can't be read or parsed.
pub fn remove_agent(name: &str) -> std::io::Result<()> {
remove_agent_at(&tool_groups_path(), name)
}
fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> {
let mut current: BTreeMap<String, Vec<String>> = super::read_map(path)?;
if current.remove(name).is_some() {
super::write_map(path, &current)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const TRUNCATED: &str = "{\n \"alice\": [\"messaging\", \"meta\"],\n \"bob\": [\"mess";
fn corrupt_file() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOOL_GROUPS_FILE);
std::fs::write(&path, TRUNCATED).expect("seed");
(dir, path)
}
#[test]
fn set_groups_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let err = set_groups_at(&path, "ruth", &["messaging".to_owned()])
.expect_err("a corrupt file must not be overwritten");
let kind = err
.downcast_ref::<std::io::Error>()
.map(std::io::Error::kind);
assert_eq!(kind, Some(std::io::ErrorKind::InvalidData));
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
}
#[test]
fn remove_agent_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let err =
remove_agent_at(&path, "alice").expect_err("a corrupt file must not be overwritten");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
}
#[test]
fn set_groups_keeps_other_agents_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOOL_GROUPS_FILE);
set_groups_at(&path, "alice", &["inbox".to_owned()]).expect("set on missing file");
set_groups_at(&path, "ruth", &["messaging".to_owned()]).expect("set");
let map: BTreeMap<String, Vec<String>> =
crate::agent_config::read_map(&path).expect("read");
assert_eq!(map["alice"], vec!["inbox".to_owned()]);
assert_eq!(map["ruth"], vec!["messaging".to_owned()]);
}
#[test]
fn prune_unknown_keeps_known_name() {
let mut map = BTreeMap::from([("alice".to_owned(), vec!["messaging".to_owned()])]);
assert!(!prune_unknown(&mut map));
assert_eq!(map["alice"], vec!["messaging".to_owned()]);
}
#[test]
fn prune_unknown_keeps_a_hand_edited_differently_cased_known_name() {
let mut map = BTreeMap::from([("alice".to_owned(), vec!["Messaging".to_owned()])]);
assert!(!prune_unknown(&mut map));
assert_eq!(map["alice"], vec!["Messaging".to_owned()]);
}
#[test]
fn prune_unknown_drops_unrecognised_name() {
let mut map = BTreeMap::from([("alice".to_owned(), vec!["diagnostics".to_owned()])]);
assert!(prune_unknown(&mut map));
assert!(!map.contains_key("alice"));
}
#[test]
fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() {
let mut map = BTreeMap::from([(
"alice".to_owned(),
vec!["messaging".to_owned(), "scheduling".to_owned()],
)]);
assert!(prune_unknown(&mut map));
assert_eq!(map["alice"], vec!["messaging".to_owned()]);
}
#[test]
fn read_from_drops_an_unknown_name_still_on_disk_without_rewriting_the_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOOL_GROUPS_FILE);
std::fs::write(&path, r#"{"alice": ["messaging", "diagnostics"]}"#).expect("seed");
// Exercises `read()`'s own wiring, not a hand-rolled equivalent —
// deleting the `prune_unknown` call inside `read_from` must fail
// this assertion.
let map = read_from(&path).expect("read_from");
assert_eq!(map["alice"], vec!["messaging".to_owned()]);
// `read()` itself never writes — the stale name is still on disk.
let on_disk = std::fs::read_to_string(&path).expect("read file");
assert!(on_disk.contains("diagnostics"), "{on_disk}");
}
}

View file

@ -9,11 +9,11 @@
//! dashboard's tree view. All of that is gone; what the file is
//! *for* now is the one thing that survived the removal — naming every
//! agent, which is the set a
//! [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
//! [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets
//! bind-mounted ([`all_agents`]).
//!
//! Broader-than-your-own bind-mount grants are **not** stored here: they
//! hang off the `ManageRootAgent` capability the swarm grants
//! hang off the `ManageRootAgent` permission the swarm grants
//! (`swarm_permissions`). See
//! `lifecycle::set_nspawn_flags`.
@ -65,7 +65,7 @@ fn read_from(path: &Path) -> std::io::Result<BTreeSet<String>> {
}
/// Every agent the roster knows about, in name order. This is the set
/// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
/// a [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets
/// bind-mounted, and it is deliberately unfiltered: that capability means
/// "may manage any agent", so the set is all of them.
///

View file

@ -552,52 +552,6 @@ impl Coordinator {
});
}
/// Best-effort synchronous snapshot of live container names (the
/// logical agent names from the last `nixos-container list` scan).
/// Returns `None` when the roster cache lock is momentarily
/// contended. The async `containers_snapshot` is the reliable path
/// for request handlers; this exists for the sync SSE snapshot emits.
#[must_use]
pub fn live_container_names_blocking(&self) -> Option<HashSet<String>> {
self.last_containers
.try_lock()
.ok()
.map(|m| m.keys().cloned().collect())
}
/// Emit a `ToolGroupsChanged` snapshot event. Called from the
/// rebuild-queue worker after a `PermChange` / `ToolGroups` entry
/// commits the JSON file, so the P3RM1SS10NS tab updates live.
pub fn emit_tool_groups_snapshot(self: &Arc<Self>) {
use hive_sh4re::permissions::ToolGroup;
let groups = ToolGroup::ALL.iter().map(|g| <&str>::from(*g)).collect();
let descriptions = ToolGroup::ALL
.iter()
.map(|g| (<&str>::from(*g), g.description()))
.collect();
let assignments = match crate::tool_groups::read() {
Ok(map) => map,
Err(e) => {
tracing::warn!(error = ?e, "tool-groups snapshot skipped");
return;
}
};
let roster = self.live_container_names_blocking().unwrap_or_default();
let (agents, effective) = crate::dashboard::permissions::roster_and_effective(
roster,
&assignments,
&crate::dashboard::permissions::tool_group_default_names(),
);
self.emit_dashboard_event(DashboardEvent::ToolGroupsChanged {
seq: self.next_seq(),
groups,
descriptions,
assignments,
agents,
effective,
});
}
/// Subscribe to the shutdown watch channel. Background tasks call
/// this at spawn time and break their loop when the receiver
/// transitions to `true` (via `Coordinator::request_shutdown`).

View file

@ -37,7 +37,6 @@ use crate::lifecycle;
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group assignment for agents"),
(name = "rebuild_queue", description = "rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
@ -56,7 +55,6 @@ mod health;
mod lifecycle_ops;
mod meta_inputs;
mod misc_api;
pub(crate) mod permissions;
mod rebuild_queue;
mod state_files;
mod state_snapshot;
@ -124,11 +122,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(build_logs::get_build_logs_agent))
.routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
.routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill))
@ -376,11 +369,6 @@ mod router_build_probe {
.routes(routes!(build_logs::get_build_logs_agent))
.routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
.routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill))

View file

@ -1,403 +0,0 @@
//! Tool-group permission endpoints for the dashboard.
//!
//! Read endpoints return the full set of known groups plus
//! descriptions and the per-agent assignment map (the UI never hard-codes
//! the lists). Write endpoints validate, then enqueue a `PermChange` so the
//! JSON file write is serialised through the FIFO rebuild worker.
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use problem_details::ProblemDetails;
use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
#[derive(Serialize, ToSchema)]
pub(super) struct ToolGroupsSnapshot {
/// Ordered list of all known tool-group names. Drives the column
/// headers in the tool-groups table — the UI does not hard-code them.
groups: Vec<&'static str>,
/// Short description for each group name. Keys match `groups` entries.
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent *explicit* assignment map. Absent agents use the role
/// default — the UI uses presence here to badge an agent "(default)".
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Every agent the operator can manage (live roster ∪ explicit keys),
/// sorted. The UI lists rows from this so default-perms agents always
/// appear without depending on a separately-loaded container list.
agents: Vec<String>,
/// Per-agent *effective* groups: the explicit entry when present, else
/// the role default the harness actually applies. Drives the checkbox
/// state so default agents show their real groups, not blank.
effective: std::collections::BTreeMap<String, Vec<String>>,
}
/// Every known tool-group name + description,
/// plus the per-agent explicit/effective assignment maps.
#[utoipa::path(
get,
path = "/api/tool-groups",
responses(
(status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot),
(status = 500, description = "tool-groups file unreadable"),
),
tag = "permissions"
)]
pub(super) async fn get_tool_groups(
State(state): State<AppState>,
) -> Result<axum::Json<ToolGroupsSnapshot>, ProblemDetails> {
let groups = hive_sh4re::permissions::ToolGroup::ALL
.iter()
.map(|g| <&str>::from(*g))
.collect();
let descriptions = hive_sh4re::permissions::ToolGroup::ALL
.iter()
.map(|g| (<&str>::from(*g), g.description()))
.collect();
let assignments = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
let roster = state
.coord
.containers_snapshot()
.await
.into_iter()
.map(|c| c.name);
let (agents, effective) =
roster_and_effective(roster, &assignments, &tool_group_default_names());
Ok(axum::Json(ToolGroupsSnapshot {
groups,
descriptions,
assignments,
agents,
effective,
}))
}
/// A permission file that exists but can't be read or parsed. Surfaced
/// as a 500 rather than an empty table, which would read as "nobody has
/// any grants".
fn unreadable(e: &std::io::Error) -> ProblemDetails {
ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR).with_detail(e.to_string())
}
/// The role-default tool-group names the harness falls back to for an
/// agent with no explicit entry (`ToolGroup::AGENT_DEFAULT`). Shared by
/// the HTTP snapshot and the SSE emit so the effective values match what
/// the container actually runs with.
#[must_use]
pub(crate) fn tool_group_default_names() -> Vec<&'static str> {
hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT
.iter()
.map(|g| <&str>::from(*g))
.collect()
}
/// Build the `(agents, effective)` pair for a permissions snapshot.
/// `agents` is the sorted union of the live roster and any agent that
/// already has an explicit entry; `effective[agent]` is the explicit
/// assignment when present, else `default` (the role fallback). Shared by
/// the tool-groups snapshots on both the HTTP and SSE paths so the two
/// surfaces never drift.
#[must_use]
pub(crate) fn roster_and_effective(
roster: impl IntoIterator<Item = String>,
explicit: &std::collections::BTreeMap<String, Vec<String>>,
default: &[&'static str],
) -> (Vec<String>, std::collections::BTreeMap<String, Vec<String>>) {
let mut names: std::collections::BTreeSet<String> = roster.into_iter().collect();
names.extend(explicit.keys().cloned());
let agents: Vec<String> = names.into_iter().collect();
let effective = agents
.iter()
.map(|a| {
let v = explicit
.get(a)
.cloned()
.unwrap_or_else(|| default.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>());
(a.clone(), v)
})
.collect();
(agents, effective)
}
#[derive(Deserialize, ToSchema)]
pub(super) struct SetToolGroupsBody {
groups: Vec<String>,
}
/// Replace `agent`'s explicit tool-group
/// assignment (JSON body `{"groups": [...]}`).
#[utoipa::path(
post,
path = "/api/tool-groups/{agent}",
params(("agent" = String, Path, description = "agent name")),
request_body = SetToolGroupsBody,
responses(
(status = 200, description = "tool-groups queued for write", body = String),
(status = 400, description = "unknown group name, or no such agent"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_tool_groups(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetToolGroupsBody>,
) -> Result<Response, ProblemDetails> {
let logical = strip_container_prefix(&name);
// `guard_agent_name` yields a ready-made rejection `Response`; pass it
// through as `Ok` (axum sends it verbatim) rather than re-deriving a
// `ProblemDetails` — the guard is shared with `-> Response` handlers.
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
}
// Validate group names before queuing — fail fast so the operator
// sees the error immediately rather than waiting for the worker.
if let Err(e) = crate::tool_groups::validate_groups(&body.groups) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
}
// Submit a PermChange DAG: the JSON file write commits under
// META_LOCK inside the WritePermFile node, so concurrent
// batch-apply actions for different agents never race on the
// shared tool-groups.json.
state
.coord
.job_queue
.insert_job(|b| {
crate::job_queue::templates::perm_change(
b,
&logical,
crate::job_queue::PermPayload::ToolGroups {
groups: body.groups.clone(),
},
);
Vec::new()
})
.expect("template-declared shapes are acyclic");
state.coord.emit_rebuild_queue_snapshot();
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
Ok((StatusCode::OK, "ok").into_response())
}
/// One agent's slice of a batch permission change. An omitted
/// `tool_groups` leaves the agent untouched, an empty array clears it, a
/// populated array fully replaces it (same replace semantics as the
/// per-agent endpoint).
#[derive(Deserialize, ToSchema)]
pub(super) struct PermChangeBody {
agent: String,
#[serde(default)]
tool_groups: Option<Vec<String>>,
}
#[derive(Deserialize, ToSchema)]
pub(super) struct BatchPermsBody {
changes: Vec<PermChangeBody>,
}
/// Batch permission apply — `POST /api/permissions`.
///
/// The save-all permissions UI sends only the agents whose groups
/// changed; each gets one `PermChange`. The whole batch is atomic: every
/// change is validated up front and on any validation error nothing is
/// written or enqueued.
#[utoipa::path(
post,
path = "/api/permissions",
request_body = BatchPermsBody,
responses(
(status = 200, description = "batch permission change queued", body = String),
(status = 400, description = "unknown tool-group name, or no such agent"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_permissions(
State(state): State<AppState>,
axum::Json(body): axum::Json<BatchPermsBody>,
) -> Result<Response, ProblemDetails> {
// Phase 1 — validate everything before touching any file or the
// queue, so a bad entry fails the whole POST with zero side effects.
// No-op rows (field omitted) are skipped, not errors.
let mut staged: Vec<(String, Vec<String>)> = Vec::new();
for change in &body.changes {
let logical = strip_container_prefix(&change.agent);
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
}
if let Some(groups) = &change.tool_groups {
if let Err(e) = crate::tool_groups::validate_groups(groups) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
}
staged.push((logical, groups.clone()));
}
}
// Phase 2 — submit one PermChange DAG per affected agent.
for (logical, groups) in staged {
state
.coord
.job_queue
.insert_job(|b| {
crate::job_queue::templates::perm_change(
b,
&logical,
crate::job_queue::PermPayload::ToolGroups { groups },
);
Vec::new()
})
.expect("template-declared shapes are acyclic");
state.coord.emit_rebuild_queue_snapshot();
tracing::info!(agent = %logical, "operator: batch perm change via dashboard");
}
Ok((StatusCode::OK, "ok").into_response())
}
/// Agent names that have explicit tool-group entries but are
/// not in the live container roster AND not in the kept-state directory
/// list (i.e. truly gone — renamed or destroyed agents whose JSON entries
/// persisted). The client uses this to drive the "stale permission entries"
/// sub-section in K3PT ST4T3 without having to fetch three separate
/// endpoints and perform set arithmetic on the client side.
#[derive(Serialize, ToSchema)]
pub(super) struct StalePermsResponse {
/// Ghost agent names, sorted. Empty list → no stale entries.
stale: Vec<String>,
}
/// Agent names with explicit permission
/// entries but no matching live container or kept-state dir.
#[utoipa::path(
get,
path = "/api/permissions/stale",
responses(
(status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse),
(status = 500, description = "tool-groups file unreadable"),
),
tag = "permissions"
)]
pub(super) async fn get_stale_permissions(
State(state): State<AppState>,
) -> Result<axum::Json<StalePermsResponse>, ProblemDetails> {
// Live container names — includes stopped-but-configured containers.
let live: std::collections::HashSet<String> = state
.coord
.containers_snapshot()
.await
.into_iter()
.map(|c| c.name)
.collect();
// Kept-state directories: every agent that ever had a state dir on disk,
// including both live containers (already in `live`) and soft-deleted
// tombstones (destroyed but kept). A name present here is either live
// or a properly-removed tombstone — neither is a ghost.
let kept: std::collections::HashSet<String> =
crate::coordinator::Coordinator::kept_state_names()
.into_iter()
.map(hive_types::Ident::into_string)
.collect();
// Known = live roster ∪ kept-state names.
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
// `tgs` is a `BTreeMap`, so its keys are already sorted.
let ghost_names: Vec<String> = tgs.keys().filter(|n| !known.contains(n)).cloned().collect();
Ok(axum::Json(StalePermsResponse { stale: ghost_names }))
}
/// Clear all explicit permission entries for a named agent without
/// requiring it to exist in the live roster.
///
/// Used by the P3RM1SS10NS tab's "remove" button for agents that have
/// stale explicit entries in `tool-groups.json` but are no longer
/// running (e.g. an agent that was renamed or destroyed while its JSON
/// entries persisted).
///
/// Bypasses `guard_agent_name`'s live-roster check intentionally —
/// the whole point is to remove entries for non-roster agents. Only
/// the format check ([`Ident::parse`]) is applied. No rebuild is
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshot
/// updates the P3RM1SS10NS tab live.
#[utoipa::path(
delete,
path = "/api/permissions/{agent}",
params(("agent" = String, Path, description = "agent name (need not be live)")),
responses(
(status = 200, description = "stale permission entries cleared", body = String),
(status = 400, description = "bad agent name"),
(status = 500, description = "tool-groups file write failed"),
),
tag = "permissions"
)]
pub(super) async fn delete_agent_permissions(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
let logical = match Ident::parse(&strip_container_prefix(&name)) {
Ok(n) => n,
Err(reason) => {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
};
let removed = crate::tool_groups::remove_agent(logical.as_str());
state.coord.emit_tool_groups_snapshot();
// Surface an I/O error as 500 so the frontend's `!resp.ok` path
// fires and the operator sees a meaningful message rather than a
// silent "success" followed by the row reappearing unchanged.
if let Err(e) = removed {
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to remove permission entries for {logical}: {e}"),
)
.into_response();
}
tracing::info!(agent = %logical, "operator: cleared stale permission entries via dashboard");
(StatusCode::OK, "ok").into_response()
}
#[cfg(test)]
mod tests {
use super::roster_and_effective;
use std::collections::BTreeMap;
#[test]
fn effective_unions_roster_and_explicit_and_applies_default() {
let mut explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
explicit.insert("iris".to_owned(), vec!["messaging".to_owned()]);
// `ruth` has an explicit entry but isn't in the live roster (e.g.
// momentarily not listed) — it must still appear and keep its
// explicit value.
explicit.insert(
"ruth".to_owned(),
vec!["messaging".to_owned(), "lifecycle".to_owned()],
);
let roster = ["damocles".to_owned(), "iris".to_owned()];
let default = ["messaging", "meta", "inbox", "execution"];
let (agents, effective) = roster_and_effective(roster, &explicit, &default);
// Sorted union of roster + explicit keys.
assert_eq!(agents, vec!["damocles", "iris", "ruth"]);
// Entry-less agent (damocles) → role default.
assert_eq!(
effective["damocles"],
vec!["messaging", "meta", "inbox", "execution"]
);
// Explicit entries kept verbatim.
assert_eq!(effective["iris"], vec!["messaging"]);
assert_eq!(effective["ruth"], vec!["messaging", "lifecycle"]);
}
#[test]
fn empty_default_yields_empty_effective_for_entryless() {
let explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]);
assert_eq!(agents, vec!["atlas"]);
assert!(effective["atlas"].is_empty());
}
}

View file

@ -141,24 +141,6 @@ pub enum DashboardEvent {
/// (transients, container state), and a poll loop would be
/// slower for the same information.
RebuildQueueChanged { seq: u64 },
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
/// Emitted from the rebuild-queue worker after a `PermChange`
/// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS
/// tab update live when the worker applies a queued change.
ToolGroupsChanged {
seq: u64,
/// Ordered list of all known tool-group names (column headers).
groups: Vec<&'static str>,
/// Short description for each group name (tooltip).
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent *explicit* assignment map; absent agents use the role
/// default (the UI badges those "(default)").
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Sorted roster the operator can manage (live ∪ explicit keys).
agents: Vec<String>,
/// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`).
effective: std::collections::BTreeMap<String, Vec<String>>,
},
}
impl DashboardEvent {
@ -185,7 +167,6 @@ impl DashboardEvent {
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
}
}
}
@ -260,14 +241,6 @@ mod tests {
running: false,
},
DashboardEvent::RebuildQueueChanged { seq: 1 },
DashboardEvent::ToolGroupsChanged {
seq: 1,
groups: Vec::new(),
descriptions: std::collections::BTreeMap::new(),
assignments: std::collections::BTreeMap::new(),
agents: Vec::new(),
effective: std::collections::BTreeMap::new(),
},
];
for ev in samples {
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");

View file

@ -120,7 +120,6 @@ pub(super) async fn run_node(
// The payload rides the node and is destructured here, so the executor
// takes it directly instead of re-matching the kind behind a `bail!`
// that could never fire.
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
NodeKind::EmitRebuilt { ok, .. } => {
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
Ok(())
@ -790,30 +789,6 @@ async fn run_write_dropin(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
Ok(())
}
/// Write + commit the perm file(s) (fused under `META_LOCK` so the
/// working tree is never left dirty), then emit the P3RM1SS10NS-tab
/// snapshots so the dashboard reflects the new assignment.
async fn run_write_perm_file(
coord: &Arc<Coordinator>,
name: &str,
payload: &super::model::PermPayload,
) -> Result<()> {
use super::model::PermPayload;
// Runs under the deploy window (it declares `Resource::MetaWindow`): a
// perm commit landing inside another node's staged prepare→finalize
// window would sweep the staged deploy lock into its commit (the
// commits are also path-limited in meta.rs — belt and braces).
match payload {
PermPayload::ToolGroups { groups } => {
crate::meta::commit_tool_groups(name, groups)
.await
.with_context(|| format!("commit tool-groups for {name}"))?;
coord.emit_tool_groups_snapshot();
}
}
Ok(())
}
/// Compute which agents a `nix flake update <inputs>` on the meta
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
/// `inputs` or any input under `hyperhive` → every container;

View file

@ -43,7 +43,7 @@ use hive_jobq::{Graph, NodeId};
use hive_jobq_wire::{GraphNode, GraphWire};
use tokio::sync::Notify;
pub use model::{NodeKind, PermPayload, State};
pub use model::{NodeKind, State};
use resource::Resource;
/// A job under construction: `hive_jobq`'s builder over this queue's payload

View file

@ -1,7 +1,7 @@
//! Data model for the generic job-DAG queue: the node kinds — the primitive
//! operations — and what each one carries. The `State` / `PermPayload` wire
//! enums live in `hive_host_sock::jobs` (they travel on the host admin
//! socket) and are re-exported here for the queue's internal use. The graph
//! operations — and what each one carries. The `State` wire
//! enum lives in `hive_host_sock::jobs` (it travels on the host admin
//! socket) and is re-exported here for the queue's internal use. The graph
//! itself is served through `hive_jobq_wire`'s generic projection — there is
//! no second, typed view of it any more.
//!
@ -12,7 +12,7 @@
//! job is exactly the nodes it declared. See `docs/scheduler/coordinator.md::Job queue`
//! for the full design.
pub use hive_host_sock::jobs::{PermPayload, State};
pub use hive_host_sock::jobs::State;
use serde::Serialize;
/// The primitive operations — each kind maps to one executor fn in
@ -98,12 +98,10 @@ pub enum NodeKind {
PauseDrain { agent: String },
/// `set_nspawn_flags` + `set_resource_limits` + daemon-reload.
WriteDropin { agent: String },
/// Commit `tool-groups.json` per its `payload`.
WritePermFile { agent: String, payload: PermPayload },
/// Group root of a rebuild subtree — the **brace**, scoped to one
/// agent. Why braces exist and what they cost: _Braces_.
AgentWindow { agent: String },
/// Tail node of a rebuild / perm-change: emit this agent's `Rebuilt`
/// Tail node of a rebuild: emit this agent's `Rebuilt`
/// manager event. One node per agent *and* per outcome; no cancel
/// variant (a cancelled DAG never ran, so there is no rebuild to
/// report).
@ -185,7 +183,6 @@ impl NodeKind {
| NodeKind::PauseSignal { agent }
| NodeKind::PauseDrain { agent }
| NodeKind::WriteDropin { agent }
| NodeKind::WritePermFile { agent, .. }
| NodeKind::AgentWindow { agent }
| NodeKind::EmitRebuilt { agent, .. }
| NodeKind::SetWanted { agent, .. } => agent,

View file

@ -20,7 +20,7 @@
//! `lifecycle::is_running` read), so [`super::power`] assembles them out of
//! the primitives this module exports ([`rebuild_nodes`]).
use super::model::{NodeKind, PermPayload};
use super::model::NodeKind;
use super::resource::Resource;
use super::{Handle, JobBuilder};
@ -403,26 +403,6 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
.after_ok(last);
}
/// Perm change: commit the JSON file, then the rebuild subgraph so
/// the updated `HIVE_TOOL_GROUPS` env var takes
/// effect in the container. Group-roots are `WritePermFile` plus the rebuild
/// subgraph's `MetaSync` / `Prebuild` / `Reconcile`, so the `EmitRebuilt` tail
/// edges all four.
pub fn perm_change(builder: &JobBuilder, agent: &str, payload: PermPayload) {
let write = builder
.node(NodeKind::WritePermFile {
agent: agent.to_owned(),
payload,
})
.needs(Resource::MetaWindow);
let roots = rebuild_nodes(builder, agent, true, Some(write));
emit_rebuilt_tails(
builder,
agent,
&[write, roots.meta_sync, roots.agent_window, roots.reconcile],
);
}
/// Meta-input lock bump. The `MetaLock` executor grows one rebuild subgraph
/// per affected agent into *this same* DAG on completion (declared onto the
/// builder it was handed) — appended *after* the bump lands so their prebuilds

View file

@ -1515,38 +1515,6 @@ fn destroy_container_must_not_suppress_crash_watch() {
);
}
#[test]
fn perm_change_shape_prefixes_rebuild_chain() {
let q = JobQueue::new(1);
insert(&q, |builder| {
templates::perm_change(
builder,
"agent-a",
PermPayload::ToolGroups { groups: vec![] },
);
});
assert_eq!(
declared_shape(&q)
.iter()
.map(|d| d.kind)
.collect::<Vec<_>>(),
vec![
"write_perm_file",
"meta_sync",
"agent_window",
"prebuild",
"stop_for_update",
"swap",
"rebuild_bookkeeping",
"reconcile",
// the ok / !ok tail pair
"emit_rebuilt",
"emit_rebuilt",
],
"the perm write prefixes an otherwise ordinary rebuild chain"
);
}
// ---- standalone sweeps ----
#[test]

View file

@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_priv_sock::{BindMount, CredentialMount};
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
use crate::coordinator::{AgentPaths, HiveEnv};
@ -386,7 +386,7 @@ fn bind_own_agent_dirs(agent_name: &str, notes_dir: &Path, binds: &mut Vec<BindM
/// agent starts without the cross-agent mounts, so one corrupt file never
/// blocks any agent's start, spawn or swap.
fn holds_manage_root_agent(agent_name: &str, path: &Path) -> bool {
crate::capabilities::holds(path, agent_name, Capability::ManageRootAgent).unwrap_or_else(|e| {
crate::permissions::holds(path, agent_name, Permission::ManageRootAgent).unwrap_or_else(|e| {
tracing::error!(
agent = %agent_name,
path = %path.display(),
@ -567,25 +567,25 @@ async fn set_nspawn_flags(
read_only: true,
});
// `ManageRootAgent` capability: mount *every* agent in the hive as a
// `ManageRootAgent` permission: mount *every* agent in the hive as a
// virtual child. Enables recovery — the holder can update another
// agent's config even when that agent is down. Also grants RO access
// to /applied and /meta.
//
// ⚠️ "every agent" is the definition of this capability, not an
// ⚠️ "every agent" is the definition of this permission, not an
// accident of how the set is computed. The grant used to hang off a
// `can_manage_top_level_agents` role and cover `top_level_agents()`
// — i.e. `parent.is_none()` — which was "everything outside the
// hierarchy". With that hierarchy removed, every agent is
// parentless, so that set *was* every agent anyway; the capability
// parentless, so that set *was* every agent anyway; the permission
// now says so out loud instead of deriving it from a field that no
// longer discriminates.
//
// This is the *only* cross-agent mount left. Dropping the topology
// parent field took with it the unconditional grant every
// agent used to get over its own direct children — so an agent with
// no capability now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::capabilities::path()) {
// no permission now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::permissions::path()) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so
// binding the holder as its own virtual child reproduced the two
@ -819,7 +819,7 @@ mod tests {
fn corrupt_swarm_permissions_deny_without_failing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("swarm-agent-permissions.json");
let truncated = "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag";
let truncated = "{\n \"ruth\": {\"permissions\": [\"manage_root_ag";
std::fs::write(&path, truncated).expect("seed");
assert!(!holds_manage_root_agent("ruth", &path));
assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes());
@ -835,7 +835,7 @@ mod tests {
assert!(!holds_manage_root_agent("alice", &path));
std::fs::write(
&path,
r#"{"ruth": {"capabilities": []}, "alice": {"capabilities": ["manage_root_agent"]}}"#,
r#"{"ruth": {"permissions": []}, "alice": {"permissions": ["manage_root_agent"]}}"#,
)
.expect("seed");
assert!(!holds_manage_root_agent("ruth", &path));

View file

@ -43,7 +43,7 @@ mod swarm_status;
mod test_env;
mod workers;
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
pub(crate) use agent_config::{limits, permissions, resource_limits, topology};
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
pub(crate) use stores::{broker, build_logs, db, power};
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};

View file

@ -45,22 +45,14 @@ pub struct AgentSpec {
pub port: u16,
}
/// Stage every generated meta JSON file that exists: topology.json is
/// regenerated by `reconcile` whenever the agent set changed;
/// tool-groups is created lazily on
/// first write (`set_groups`) — absent means
/// every agent is on defaults, no file needed. Without staging, an
/// Stage topology.json if it exists: it is regenerated by `reconcile`
/// whenever the agent set changed. Without staging, an
/// existing-but-untracked file shows up as untracked in the meta repo,
/// which can confuse nix's dirty-tree fetch. `git add` is a no-op when
/// content is unchanged.
async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
for (path, name) in [
(crate::topology::topology_path(), "topology.json"),
(crate::tool_groups::tool_groups_path(), "tool-groups.json"),
] {
if path.exists() {
git(dir, &["add", name]).await?;
}
if crate::topology::topology_path().exists() {
git(dir, &["add", "topology.json"]).await?;
}
Ok(())
}
@ -80,7 +72,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let dir = crate::paths::meta_root();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_hive_flake(hive, agents)?;
let new_flake = render_hive_flake(hive, agents);
let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
@ -193,7 +185,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
// Only ever a REMOVAL now; see `is_embedded_ca_name`.
f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"),
"topology.json" => Some("topology"),
"tool-groups.json" => Some("tool-groups"),
_ => None,
})
.collect();
@ -299,50 +290,35 @@ pub async fn lock_update_hyperhive() -> Result<()> {
git_commit_paths(&dir, "bump hyperhive", &["flake.lock"]).await
}
/// Write the tool-groups file for `agent` and commit it atomically
/// under `META_LOCK`. Ensures the JSON change is staged + committed
/// before the next `sync_agents` runs, so the
/// working tree is never left dirty by an untimely `PermChange` write.
pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> {
let _guard = META_LOCK.lock().await;
crate::tool_groups::set_groups(agent, groups)?;
let dir = crate::paths::meta_root();
if crate::tool_groups::tool_groups_path().exists() {
git(&dir, &["add", "tool-groups.json"]).await?;
}
if paths_dirty(&dir, &["tool-groups.json"]).await? {
git_commit_paths(
&dir,
&format!("set tool-groups for {agent}"),
&["tool-groups.json"],
)
.await?;
}
Ok(())
}
/// Delete `capabilities.json` from the meta repo and commit the removal under
/// `META_LOCK`. Its grants live in the swarm's `agent-permissions` bucket
/// Delete the meta repo's permission files and commit the removal under
/// `META_LOCK`. Their grants live in the swarm's `agent-permissions` bucket
/// once `workers::permissions_import` has sent them.
///
/// # Errors
///
/// Removing the file or a git stage/commit step fails.
pub async fn remove_capabilities_file() -> Result<()> {
const FILE: &str = crate::workers::permissions_import::META_FILE;
/// Removing a file or a git stage/commit step fails.
pub async fn remove_permission_files() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
match std::fs::remove_file(dir.join(FILE)) {
let mut removed = Vec::new();
for file in crate::workers::permissions_import::META_FILES {
match std::fs::remove_file(dir.join(file)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {FILE}")),
Err(e) => return Err(e).with_context(|| format!("removing {file}")),
}
git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", FILE]).await?;
if paths_dirty(&dir, &[FILE]).await? {
git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", file]).await?;
// Only a tracked file has a removal to commit; naming an untracked
// one makes `git commit -- <path>` fail.
if paths_dirty(&dir, &[file]).await? {
removed.push(*file);
}
}
if !removed.is_empty() {
git_commit_paths(
&dir,
"capabilities moved to the swarm's agent-permissions",
&[FILE],
"permissions moved to the swarm's agent-permissions",
&removed,
)
.await?;
}
@ -351,7 +327,7 @@ pub async fn remove_capabilities_file() -> Result<()> {
/// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`]
/// renders it.
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> {
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> String {
render_flake(
&hive.hyperhive_flake,
&hive.hyperhive_docs_flake,
@ -378,7 +354,7 @@ fn render_flake(
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> Result<String> {
) -> String {
render_flake_with_lookup(
hyperhive_flake,
docs_flake,
@ -781,7 +757,7 @@ fn render_flake_with_lookup<F>(
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
lookup: F,
) -> Result<String>
) -> String
where
F: Fn(&str) -> Vec<&'static str>,
{
@ -860,7 +836,7 @@ where
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null }}:"
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
);
out.push_str(
r#" let
@ -869,7 +845,6 @@ where
else hyperhive.nixosConfigurations.agent-base;
input = inputs."agent-${name}";
service = "hive-agent";
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
in
base.extendModules {
modules = [
@ -1046,7 +1021,7 @@ where
}
out.push_str(
r" };
systemd.services.${service}.environment = toolGroupsEnv // {
systemd.services.${service}.environment = {
HIVE_PORT = toString port;
HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort;
@ -1090,34 +1065,18 @@ where
nixosConfigurations = {
"#,
);
// Propagated, never read as empty: an agent with no tool-groups entry
// renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails
// open for any agent whose explicit entry is narrower.
let tool_groups_map = crate::tool_groups::read()?;
for spec in agents {
// Emit `toolGroups = "group1,group2"` when the operator has
// explicitly configured groups for this agent. Absent entry = null
// = harness falls back to AGENT_DEFAULT (no env var emitted,
// no rebuild cascade for agents whose groups haven't changed).
let groups = tool_groups_map.get(&spec.name).cloned().unwrap_or_default();
let tool_groups_attr = if groups.is_empty() {
"null".to_owned()
} else {
let joined = groups.join(",");
format!("\"{joined}\"")
};
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; }};",
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; }};",
spec.name,
spec.name,
if spec.is_manager { "true" } else { "false" },
spec.port,
tool_groups_attr,
);
}
out.push_str(" };\n };\n}\n");
Ok(out)
out
}
/// Return the list of file names that are currently staged (index differs
@ -1202,7 +1161,7 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> {
/// Path-limited commit: commits ONLY the given paths, so unrelated
/// staged content can never be swept into someone else's commit. Every targeted
/// meta commit (perm files, topology, lock bumps) goes through this;
/// meta commit (limits, topology, lock bumps) goes through this;
/// only `sync_agents` uses the bare [`git_commit`], because its
/// staged set *is* its intentional commit set.
async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> {
@ -1293,29 +1252,35 @@ mod tests {
git(dir, &["init", "--initial-branch=main"])
.await
.expect("git init");
std::fs::write(dir.join("tool-groups.json"), "{}").expect("write");
std::fs::write(dir.join("resource-limits.json"), "{}").expect("write");
std::fs::write(dir.join("flake.lock"), "v1").expect("write");
git(dir, &["add", "-A"]).await.expect("add");
git_commit(dir, "seed").await.expect("seed commit");
// Something stages a new lock (uncommitted)…
std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write");
git(dir, &["add", "flake.lock"]).await.expect("stage lock");
// …and a perm change commits, path-limited.
std::fs::write(dir.join("tool-groups.json"), r#"{"alice":[]}"#).expect("write");
git(dir, &["add", "tool-groups.json"]).await.expect("add");
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
// …and a limits change commits, path-limited.
std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write");
git(dir, &["add", "resource-limits.json"])
.await
.expect("add");
git_commit_paths(
dir,
"set resource limits for alice",
&["resource-limits.json"],
)
.await
.expect("path-limited commit");
// The perm file is committed; the staged lock is not.
// The limits file is committed; the staged lock is not.
assert!(
!paths_dirty(dir, &["tool-groups.json"])
!paths_dirty(dir, &["resource-limits.json"])
.await
.expect("check"),
"perm file must be committed"
"limits file must be committed"
);
assert!(
paths_dirty(dir, &["flake.lock"]).await.expect("check"),
"staged deploy lock must survive the perm commit"
"staged deploy lock must survive the limits commit"
);
}
@ -1352,8 +1317,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
// nixpkgs is a top-level input with an explicit URL; hyperhive
// follows it.
assert!(
@ -1399,8 +1363,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
!out.contains("hyperhive-docs"),
"no docs input/source when docs_flake is empty:\n{out}"
@ -1418,8 +1381,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
out.contains(r#"networking.hostName = "h-${name}";"#),
"an agent container with no hostname of its own is called `nixos`, \
@ -1459,8 +1421,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
out.contains(
"services.hyperhive.agent.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";"
@ -1488,8 +1449,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
!out.contains("claudeCodePath"),
"no claude assignment when unpinned:\n{out}"
@ -1509,8 +1469,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""),
"expected fallback follows:\n{out}"
@ -1545,8 +1504,7 @@ mod tests {
sample_spec("dmatrix", false, 9003),
],
lookup,
)
.expect("render flake");
);
// bitburner declares nixpkgs → follows emitted.
assert!(
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
@ -1575,8 +1533,7 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
)
.expect("render flake");
);
// No agent-side follows when the lookup reports nothing
// declared — protects agents whose flake.lock can't be read
// (missing / unparsable) from being broken by a follows on a
@ -1613,8 +1570,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
unsafe {
std::env::remove_var("HIVE_FORGE_URL");
}
@ -1660,7 +1616,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
let alpha = render_on("alpha-hive", "alpha-hive.example.test");
let beta = render_on("beta-hive", "beta-hive.example.test");
@ -1840,8 +1795,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
unsafe {
std::env::remove_var("HIVE_FORGE_URL");
std::env::remove_var("HIVE_MATRIX_URL");
@ -1877,8 +1831,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
let want = format!(
"agent-alice.url = \"git+file://{}\"",
crate::paths::applied_dir("alice").display()
@ -1923,7 +1876,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
// A leftover peer-CA file + the env var that names it. Both must be
@ -2016,7 +1968,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
unsafe {
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
@ -2091,7 +2042,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
unsafe {
std::env::remove_var("HYPERHIVE_GITHUB_DISABLED");
@ -2123,8 +2073,8 @@ mod tests {
..HiveEnv::default()
};
let agents = [sample_spec("alice", false, 9001)];
let small = render_hive_flake(&hive("4G"), &agents).expect("render 4G");
let large = render_hive_flake(&hive("16G"), &agents).expect("render 16G");
let small = render_hive_flake(&hive("4G"), &agents);
let large = render_hive_flake(&hive("16G"), &agents);
assert_eq!(
small, large,
"the rendered flake must not depend on the memory cap"

View file

@ -79,8 +79,8 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
/// Bind + serve the manager socket. This is now **pure transport**: it grants
/// no authority of its own — it just serves requests as `agent = MANAGER_AGENT`
/// ("ruth"), and ruth's reach comes entirely from the capabilities /
/// tool-groups it holds, identical to connecting on a per-agent socket — ruth
/// ("ruth"), and ruth's reach comes entirely from the permissions it
/// holds, identical to connecting on a per-agent socket — ruth
/// uses the standard per-agent runtime dir + socket, with no dedicated helpers.
pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;

View file

@ -109,7 +109,7 @@ pub fn spawn(
shutdown.clone(),
));
// The capabilities the swarm grants, and the import of the meta repo's.
// The permissions the swarm grants, and the import of the meta repo's.
tokio::spawn(crate::workers::swarm_permissions::follow(
client.clone(),
std::sync::Arc::clone(&coord),

View file

@ -23,7 +23,6 @@ use anyhow::Result;
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
use crate::tool_groups;
/// Resolve the current rev of `hyperhive_flake`. For a path on disk we
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/...
@ -200,7 +199,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?;
lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?;
seed_manager_tool_groups();
if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) {
tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed");
}
@ -212,47 +210,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
Ok(())
}
/// Give ruth her privileged tool groups on the one path that creates her.
///
/// `effective_tool_groups()` has no manager-flavour fallback, so an agent
/// with no entry in `tool-groups.json` is an agent with no privileged
/// tools. Ruth needs hers from her first turn, and this is the only place
/// she is brought into existence — so it is written once, here, rather
/// than re-checked on every hive-c0re boot.
///
/// Skips a name that already has an entry: a destroy+recreate under the
/// same name must not silently reset an operator's chosen group set back
/// to the default. An unreadable file is logged and left alone: ruth has
/// already been spawned, and seeding stays best-effort like the write
/// below.
fn seed_manager_tool_groups() {
match tool_groups::groups_for(MANAGER_NAME) {
Ok(groups) if groups.is_empty() => {}
Ok(_) => {
tracing::debug!("manager tool groups already set — leaving as-is");
return;
}
Err(e) => {
tracing::warn!(
error = ?e,
"tool-groups file unreadable — not seeding ruth's tool groups"
);
return;
}
}
let all_groups: Vec<String> = hive_sh4re::permissions::ToolGroup::MANAGER_DEFAULT
.iter()
.map(|g| <&str>::from(*g).to_owned())
.collect();
match tool_groups::set_groups(MANAGER_NAME, &all_groups) {
Ok(()) => tracing::info!("seeded ruth's tool groups to MANAGER_DEFAULT (all groups)"),
Err(e) => tracing::warn!(
error = ?e,
"failed to seed ruth's tool groups — she will start without privileged tools"
),
}
}
/// Boot reconcile (see the module doc): classify every agent by rev
/// freshness + persisted `wanted` intent, submit one `Boot` DAG
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale

View file

@ -1,75 +1,146 @@
//! The one-shot move of the capabilities this hive's meta repo held
//! (`meta/capabilities.json`) into the swarm's `agent-permissions` bucket.
//! The one-shot move of the permissions this hive's meta repo held
//! (`meta/capabilities.json` and `meta/tool-groups.json`) into the swarm's
//! `agent-permissions` bucket, as one list per agent.
//!
//! At boot, before any agent starts, [`seed_local`] copies the file's grants
//! into [`crate::capabilities`] when nothing is stored there yet, so agents
//! At boot, before any agent starts, [`seed_local`] copies the files' grants
//! into [`crate::permissions`] when nothing is stored there yet, so agents
//! keep their mounts until the swarm answers. [`send_pending`] then sends the
//! grants to swarm-controller, which writes each agent's key only where there
//! is none, and on its answer deletes the file from the meta repo. Nothing
//! reads the file after that.
//! is none, and on its answer deletes both files from the meta repo. Nothing
//! reads them after that.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
};
/// The meta repo file the grants are moved out of.
pub const META_FILE: &str = "capabilities.json";
const CAPABILITIES_FILE: &str = "capabilities.json";
const TOOL_GROUPS_FILE: &str = "tool-groups.json";
fn meta_path() -> PathBuf {
crate::paths::meta_root().join(META_FILE)
}
/// The meta repo files the grants are moved out of.
pub const META_FILES: &[&str] = &[CAPABILITIES_FILE, TOOL_GROUPS_FILE];
/// The grants the meta file holds, by agent, as the swarm stores them. A name
/// that is not a [`Capability`] is dropped, with a warning.
///
/// `ruth` with no entry in a file that exists had her grant revoked: the file
/// was created holding it, and a revoke removed the entry. She is sent an
/// empty list, since unset would give her the default back.
fn grants(file: BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<Capability>> {
let mut out: BTreeMap<String, Vec<Capability>> = file
.into_iter()
.map(|(agent, names)| {
let caps = names
.iter()
.filter_map(|name| {
let cap = Capability::ALL
/// Tool groups the files may name that give nothing and have no
/// [`Permission`]: dropped.
const EMPTY_TOOL_GROUPS: &[&str] = &["lifecycle", "forge"];
type Grants = BTreeMap<String, Vec<Permission>>;
type FileMap = BTreeMap<String, Vec<String>>;
fn parse(agent: &str, name: &str) -> Option<Permission> {
let found = Permission::ALL
.iter()
.copied()
.find(|c| name.eq_ignore_ascii_case(<&str>::from(*c)));
if cap.is_none() {
tracing::warn!(%agent, capability = %name, "import: dropping unrecognised capability");
.find(|p| name.eq_ignore_ascii_case(<&str>::from(*p)));
if found.is_none()
&& !EMPTY_TOOL_GROUPS
.iter()
.any(|g| name.eq_ignore_ascii_case(g))
{
tracing::warn!(%agent, permission = %name, "import: dropping unrecognised name");
}
cap
})
found
}
/// The one list per agent the two files add up to, as the swarm stores it.
/// `None` for either file means it does not exist.
///
/// Each half is what the agent held under the old readers:
/// - tool groups: the entry's known names; an absent entry, or one naming
/// only unknown names, was the default groups. `lifecycle`/`forge` count as
/// known (they parsed) and are then dropped, so an entry of only those
/// held no tools and imports as none.
/// - capabilities: the entry's names. With the file present, `ruth` having no
/// entry means her grant was revoked: the file was created holding it, and
/// a revoke removed the entry. With no file, the default.
///
/// An agent whose list equals [`Permission::default_for`] is left out, so its
/// key stays unset and follows the default.
fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants {
let mut agents: BTreeSet<&str> = BTreeSet::new();
agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str)));
agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str)));
if caps.is_some() {
agents.insert(crate::lifecycle::MANAGER_NAME);
}
let mut out = Grants::new();
for agent in agents {
let default = Permission::default_for(agent);
let tool_default: Vec<Permission> = default
.iter()
.copied()
.filter(|p| *p != Permission::ManageRootAgent)
.collect();
(agent, caps)
})
.collect();
out.entry(crate::lifecycle::MANAGER_NAME.to_owned())
.or_default();
let tool_part = match groups.and_then(|m| m.get(agent)) {
Some(names)
if names.iter().any(|n| {
parse(agent, n).is_some()
|| EMPTY_TOOL_GROUPS.iter().any(|g| n.eq_ignore_ascii_case(g))
}) =>
{
names.iter().filter_map(|n| parse(agent, n)).collect()
}
_ => tool_default,
};
let cap_part: Vec<Permission> = match caps {
Some(m) => m
.get(agent)
.map(|names| names.iter().filter_map(|n| parse(agent, n)).collect())
.unwrap_or_default(),
None => default
.iter()
.copied()
.filter(|p| *p == Permission::ManageRootAgent)
.collect(),
};
let mut list: Vec<Permission> = Vec::new();
for perm in tool_part.into_iter().chain(cap_part) {
if !list.contains(&perm) {
list.push(perm);
}
}
let same_as_default =
list.len() == default.len() && default.iter().all(|p| list.contains(p));
if !same_as_default {
out.insert(agent.to_owned(), list);
}
}
out
}
fn read_grants(path: &Path) -> Result<Option<BTreeMap<String, Vec<Capability>>>> {
fn read_file(path: &Path) -> Result<Option<FileMap>> {
if !path.exists() {
return Ok(None);
}
let file: BTreeMap<String, Vec<String>> = serde_json::from_slice(
let map = serde_json::from_slice(
&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(Some(grants(file)))
Ok(Some(map))
}
/// Store the meta file's grants as the last-read swarm permissions, when the
/// file is still there and nothing has been stored yet.
/// The grants the files in `meta` hold, or `None` when neither exists.
fn read_grants(meta: &Path) -> Result<Option<Grants>> {
let caps = read_file(&meta.join(CAPABILITIES_FILE))?;
let groups = read_file(&meta.join(TOOL_GROUPS_FILE))?;
if caps.is_none() && groups.is_none() {
return Ok(None);
}
Ok(Some(grants(caps.as_ref(), groups.as_ref())))
}
fn meta_dir() -> PathBuf {
crate::paths::meta_root()
}
/// Store the files' grants as the last-read swarm permissions, when a file
/// is still there and nothing has been stored yet.
pub fn seed_local() {
if let Err(e) = seed_local_at(&meta_path(), &crate::capabilities::path()) {
if let Err(e) = seed_local_at(&meta_dir(), &crate::permissions::path()) {
tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed");
}
}
@ -83,39 +154,71 @@ fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> {
};
let records = grants
.into_iter()
.map(|(agent, caps)| {
.map(|(agent, perms)| {
(
agent,
AgentPermissions {
capabilities: Some(caps),
permissions: Some(perms),
},
)
})
.collect();
crate::capabilities::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo's capabilities");
crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo");
Ok(())
}
/// Send the meta file's grants to swarm-controller while the file is there,
/// and delete it once the controller has answered that it handled them.
///
/// # Errors
///
/// The file is still there: the import failed, or deleting the file did. The
/// next pass sends again; the controller keeps a key it already has.
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> Result<()> {
send_at(client, hive, &meta_path())
.await
.context("importing the meta repo's capabilities")
/// What one attempt to send the import came to.
#[derive(Debug)]
pub enum ImportOutcome {
/// No meta file is left: nothing to import.
NothingPending,
/// The controller took the grants and the files are deleted.
Imported,
/// The controller answered with a refusal; the files stay.
Refused(String),
/// No answer (the controller or the queue is down), or a file could not
/// be read or deleted; the files stay.
Unsettled(anyhow::Error),
}
async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result<()> {
let Some(capabilities) = read_grants(path)? else {
return Ok(());
impl ImportOutcome {
/// Whether the swarm's keys may be read after this outcome. While a file
/// is left, an unset key may only mean its grants are not imported yet,
/// and reading it as the default would undo them.
#[must_use]
pub fn read_allowed(&self) -> bool {
matches!(self, Self::NothingPending | Self::Imported)
}
/// Why reading is not allowed, for the caller's log and banner.
#[must_use]
pub fn into_error(self) -> Option<anyhow::Error> {
match self {
Self::NothingPending | Self::Imported => None,
Self::Refused(why) => Some(anyhow::anyhow!("the controller refused the import: {why}")),
Self::Unsettled(e) => Some(e.context("importing the meta repo's permissions")),
}
}
}
/// Send the files' grants to swarm-controller while a file is there, and
/// delete both once the controller has answered that it handled them. The
/// next pass sends again after anything but [`ImportOutcome::Imported`]; the
/// controller keeps a key it already has.
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> ImportOutcome {
match send_at(client, hive, &meta_dir()).await {
Ok(outcome) => outcome,
Err(e) => ImportOutcome::Unsettled(e),
}
}
async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result<ImportOutcome> {
let Some(permissions) = read_grants(meta)? else {
return Ok(ImportOutcome::NothingPending);
};
swarm_queue_client::ensure_connected(client)?;
let payload = serde_json::to_vec(&Import { capabilities })?;
let payload = serde_json::to_vec(&Import { permissions })?;
let reply = tokio::time::timeout(
IMPORT_REPLY_DEADLINE,
client.request(agent_permissions::import_subject(hive), payload.into()),
@ -133,65 +236,96 @@ async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result
?written,
?kept,
?skipped,
"import: the controller took the meta repo's capabilities"
"import: the controller took the meta repo's permissions"
);
crate::meta::remove_capabilities_file()
crate::meta::remove_permission_files()
.await
.context("deleting the imported capabilities file")
}
ImportReply::Failed { error } => {
anyhow::bail!("the controller refused the import: {error}")
.context("deleting the imported permission files")?;
Ok(ImportOutcome::Imported)
}
ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{FileMap, grants, seed_local_at};
use hive_sh4re::permissions::Permission::{self, *};
use super::{grants, seed_local_at};
use hive_sh4re::permissions::Capability;
fn file(json: &str) -> BTreeMap<String, Vec<String>> {
fn file(json: &str) -> FileMap {
serde_json::from_str(json).expect("decodes")
}
/// Both files add up per agent; the empty groups and unknown names drop;
/// an agent left on the default is not sent.
#[test]
fn grants_keep_known_names_and_a_revoked_ruth_stays_revoked() {
let out = grants(file(
r#"{"atlas": ["manage_root_agent", "fly_to_the_moon"], "iris": []}"#,
));
assert_eq!(out["atlas"], vec![Capability::ManageRootAgent]);
assert_eq!(out["iris"], Vec::<Capability>::new());
assert_eq!(out["ruth"], Vec::<Capability>::new());
let out = grants(file(r#"{"ruth": ["manage_root_agent"]}"#));
assert_eq!(out["ruth"], vec![Capability::ManageRootAgent]);
fn the_two_files_merge_into_one_list() {
let caps = file(r#"{"ruth": ["manage_root_agent"], "atlas": ["manage_root_agent"]}"#);
let groups = file(
r#"{"ruth": ["messaging", "meta", "inbox", "lifecycle", "execution"],
"atlas": ["messaging", "web_tools", "forge"],
"iris": ["messaging", "fly_to_the_moon"],
"sock": ["lifecycle"]}"#,
);
let out = grants(Some(&caps), Some(&groups));
assert!(!out.contains_key("ruth"), "ruth's seed is her default");
assert_eq!(out["atlas"], vec![Messaging, WebTools, ManageRootAgent]);
assert_eq!(out["iris"], vec![Messaging]);
assert_eq!(out["sock"], Vec::<Permission>::new());
}
/// The seed writes once, from the file, and never over a stored read.
/// Without a capabilities entry, ruth's existing file means a revoke; with
/// no capabilities file at all, her default stands.
#[test]
fn the_seed_copies_the_file_only_when_nothing_is_stored() {
fn a_revoked_ruth_stays_revoked() {
let out = grants(Some(&file("{}")), None);
assert_eq!(out["ruth"], vec![Messaging, Meta, Inbox, Execution]);
let out = grants(None, Some(&file(r#"{"atlas": ["messaging"]}"#)));
assert!(!out.contains_key("ruth"));
assert_eq!(out["atlas"], vec![Messaging]);
}
/// The seed writes once, from the files, and never over a stored read.
#[test]
fn the_seed_copies_the_files_only_when_nothing_is_stored() {
let dir = tempfile::tempdir().expect("tempdir");
let meta = dir.path().join("capabilities.json");
let meta = dir.path();
let stored = dir.path().join("stored.json");
seed_local_at(&meta, &stored).expect("no file is no seed");
seed_local_at(meta, &stored).expect("no file is no seed");
assert!(!stored.exists());
std::fs::write(&meta, r#"{"atlas": ["manage_root_agent"]}"#).expect("seed");
seed_local_at(&meta, &stored).expect("seeded");
assert!(
crate::capabilities::holds(&stored, "atlas", Capability::ManageRootAgent)
.expect("read")
);
assert!(
!crate::capabilities::holds(&stored, "ruth", Capability::ManageRootAgent)
.expect("read")
);
std::fs::write(
meta.join("capabilities.json"),
r#"{"atlas": ["manage_root_agent"]}"#,
)
.expect("seed");
seed_local_at(meta, &stored).expect("seeded");
assert!(crate::permissions::holds(&stored, "atlas", ManageRootAgent).expect("read"));
assert!(!crate::permissions::holds(&stored, "ruth", ManageRootAgent).expect("read"));
std::fs::write(&stored, "{}").expect("a later read");
seed_local_at(&meta, &stored).expect("left alone");
seed_local_at(meta, &stored).expect("left alone");
assert_eq!(std::fs::read_to_string(&stored).expect("read"), "{}");
}
/// The read gate, one row per outcome: only a settled import lets the
/// swarm's keys be read.
#[test]
fn keys_are_read_only_once_the_import_is_settled() {
use super::ImportOutcome;
for (outcome, allowed) in [
(ImportOutcome::NothingPending, true),
(ImportOutcome::Imported, true),
(ImportOutcome::Refused("not a hive".to_owned()), false),
(
ImportOutcome::Unsettled(anyhow::anyhow!("no responders")),
false,
),
] {
assert_eq!(outcome.read_allowed(), allowed, "{outcome:?}");
assert_eq!(outcome.into_error().is_none(), allowed);
}
}
}

View file

@ -1,25 +1,26 @@
//! Reading the capabilities the swarm grants this hive's agents.
//! Reading the permissions the swarm grants this hive's agents.
//!
//! swarm-controller keeps one key per agent in the `agent-permissions` bucket
//! ([`swarm_queue_client::agent_permissions`]). Every [`READ_INTERVAL`] this
//! reads the key of each agent this hive has and stores what it finds with
//! [`crate::capabilities`], where the container's bind mounts are worked out.
//! Those are fixed when the container starts, so an agent whose capabilities
//! changed is restarted, after its current turn.
//! [`crate::permissions`], where the container's bind mounts are worked out.
//! Those are fixed when the container starts, so an agent whose
//! `manage_root_agent` changed is restarted, after its current turn. The
//! agent applies the rest of its permissions itself.
//!
//! A failed read, or a bucket that does not answer, leaves the stored
//! permissions as they are and restarts nothing: dropping them would put an
//! agent back on its default, which can be wider than what the swarm set.
//! permissions as they are and restarts nothing.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read};
use crate::capabilities::{self, Stored};
use crate::coordinator::Coordinator;
use crate::permissions::{self, Stored};
use crate::stats::sweep_health::{self, SweepHealth};
/// How often the permissions are read.
@ -38,11 +39,13 @@ pub async fn follow(
) {
let mut health = SweepHealth::new("swarm_permissions_read", "warn", FAILURES_BEFORE_BANNER);
loop {
// While the meta repo's grants are not in the swarm yet, an unset key
// is not the swarm's answer, so nothing is read until they are.
let pass = match super::permissions_import::send_pending(&client, &hive).await {
Ok(()) => sync(&client).await,
Err(e) => Err(e),
let import = super::permissions_import::send_pending(&client, &hive).await;
let pass = if import.read_allowed() {
sync(&client).await
} else {
Err(import
.into_error()
.unwrap_or_else(|| anyhow::anyhow!("import unsettled")))
};
match pass {
Ok(changed) => {
@ -76,7 +79,7 @@ pub async fn follow(
}
/// Read every agent's key and store the result. Returns the agents whose
/// capabilities changed.
/// `manage_root_agent` changed.
async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client)?;
@ -86,7 +89,7 @@ async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
.into_iter()
.map(|spec| spec.name)
.collect();
sync_with(&agents, &capabilities::path(), |agent| async move {
sync_with(&agents, &permissions::path(), |agent| async move {
agent_permissions::read(client, &agent)
.await
.map_err(|e| anyhow::anyhow!(e))
@ -116,24 +119,26 @@ where
}
}
}
let stored = capabilities::read_at(path).context("reading the stored swarm permissions")?;
let stored = permissions::read_at(path).context("reading the stored swarm permissions")?;
if read_now == stored {
return Ok(Vec::new());
}
capabilities::write_at(path, &read_now).context("storing the swarm permissions")?;
permissions::write_at(path, &read_now).context("storing the swarm permissions")?;
tracing::info!(permissions = ?read_now, "swarm permissions: stored the swarm's permissions");
Ok(agents
.iter()
.filter(|a| {
capabilities::effective(a, stored.get(*a))
!= capabilities::effective(a, read_now.get(*a))
})
.filter(|a| manages(a, &stored) != manages(a, &read_now))
.cloned()
.collect())
}
/// Whether `agent` holds the one permission hive-c0re enforces under `stored`.
fn manages(agent: &str, stored: &Stored) -> bool {
permissions::effective(agent, stored.get(agent)).contains(&Permission::ManageRootAgent)
}
/// Restart each running agent in `changed` once its current turn is done, so
/// its container starts with the bind mounts its new capabilities give. A
/// its container starts with the bind mounts its new permissions give. A
/// stopped agent gets them at its next start.
async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
let mut running = Vec::new();
@ -145,7 +150,7 @@ async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
if running.is_empty() {
return;
}
tracing::info!(agents = ?running, "swarm permissions: capabilities changed, restarting");
tracing::info!(agents = ?running, "swarm permissions: manage_root_agent changed, restarting");
if let Err(e) = crate::job_queue::power::restart_many(coord, &running, true).await {
tracing::error!(agents = ?running, error = ?e, "swarm permissions: restart insert failed");
}
@ -188,16 +193,12 @@ mod tests {
.await
.expect("read");
assert!(changed.is_empty());
assert!(crate::permissions::read_at(&path).expect("read").is_empty());
assert!(
crate::capabilities::read_at(&path)
.expect("read")
.is_empty()
);
assert!(
crate::capabilities::holds(
crate::permissions::holds(
&path,
"ruth",
hive_sh4re::permissions::Capability::ManageRootAgent
hive_sh4re::permissions::Permission::ManageRootAgent
)
.expect("read")
);
@ -206,14 +207,14 @@ mod tests {
/// Revoking ruth's default, and granting atlas, restarts both; clearing
/// them again restarts both back.
#[tokio::test]
async fn a_changed_capability_asks_for_a_restart() {
async fn a_changed_manage_root_agent_asks_for_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"#
r#"{"permissions":[]}"#
} else {
r#"{"capabilities":["manage_root_agent"]}"#
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
@ -223,9 +224,9 @@ mod tests {
// The same answer again changes nothing.
let unchanged = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"#
r#"{"permissions":[]}"#
} else {
r#"{"capabilities":["manage_root_agent"]}"#
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
@ -238,37 +239,37 @@ mod tests {
assert_eq!(cleared, agents());
}
/// Setting ruth's default explicitly is a different record but the same
/// mounts, so it is stored and restarts nothing.
/// A change that leaves `manage_root_agent` where it was is stored and
/// restarts nothing: the agent applies tool permissions itself.
#[tokio::test]
async fn an_explicit_default_is_stored_without_a_restart() {
async fn a_tool_only_change_is_stored_without_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(if agent == "ruth" {
record(r#"{"capabilities":["manage_root_agent"]}"#)
record(r#"{"permissions":["manage_root_agent"]}"#)
} else {
AgentPermissions::default()
record(r#"{"permissions":["messaging","web_tools"]}"#)
}))
})
.await
.expect("read");
assert!(changed.is_empty());
assert!(
crate::capabilities::read_at(&path)
crate::permissions::read_at(&path)
.expect("read")
.contains_key("ruth")
.contains_key("atlas")
);
}
/// One failed read, or a bucket that does not answer, keeps what was
/// stored and restarts nothing: never a fall back to the default.
/// stored and restarts nothing.
#[tokio::test]
async fn a_failed_read_keeps_the_last_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let revoked = || Ok(Read::Current(record(r#"{"capabilities":[]}"#)));
// An empty list is atlas's default already, so only ruth changes.
let revoked = || Ok(Read::Current(record(r#"{"permissions":[]}"#)));
// atlas holds no manage_root_agent either way, so only ruth changes.
assert_eq!(
sync(&path, |_| revoked()).await.expect("read"),
vec!["ruth".to_owned()]

View file

@ -20,7 +20,7 @@ shared payload type some variants reference (`AgentStatusRow`) stays in
client implementation.
Its own `jobs` module is the exception: the job-queue vocabulary `hivectl`
needs (`Source`, `State`, `PermPayload`, `NodeId`) is protocol-local. The typed
needs (`State`, `NodeId`) is protocol-local. The typed
`DagView`/`NodeView` projection that used to live there is gone — the queue is
served as a generic graph (`hive-jobq-wire`), not as a second hand-written
view.

View file

@ -1,6 +1,5 @@
//! Vocabulary hive-c0re's job queue shares with its clients: what a
//! permission change carries ([`PermPayload`]) and the scheduler's
//! lifecycle [`State`].
//! Vocabulary hive-c0re's job queue shares with its clients: the
//! scheduler's lifecycle [`State`].
//!
//! A `Source` enum lived here too — where a job came from, rendered as the
//! "why" chip. It was a field on the DAG container, and it went with it: a
@ -15,18 +14,8 @@
//! scheduling) live in `hive-c0re::job_queue`.
//! Semantics: `docs/scheduler/coordinator.md::Job queue`.
use serde::{Deserialize, Serialize};
pub use hive_jobq::State;
/// Kind-specific payload for `Template::PermChange` DAGs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PermPayload {
/// Set the tool groups for one agent (`tool-groups.json`).
ToolGroups { groups: Vec<String> },
}
/// Node id. Carries the scheduler crate's globally-monotonic node id
/// (`hive_jobq::NodeId`) verbatim on the wire — unique across all DAGs, not
/// just within one. Consumers treat it opaquely (grouping + dep matching),

View file

@ -16,7 +16,3 @@ serde.workspace = true
# `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows.
serde_json.workspace = true
strum.workspace = true
# Facade only, for the one warn in `permissions::ToolGroup::parse_list`: an
# unknown tool-group name is skipped rather than fatal, so the log line is the
# only trace it leaves.
tracing.workspace = true

View file

@ -1,38 +1,32 @@
//! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and
//! system-level capability grants (`Capability`). Tool groups are declared
//! in the hive's `tool-groups.json`, injected into the container as an env
//! var, and read by the harness to decide which MCP tools claude actually
//! sees. Capabilities are granted in the swarm's `agent-permissions` bucket
//! and enforced by hive-c0re.
//! Per-agent authorization: the one [`Permission`] set an agent holds.
//!
//! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]):
//! that set is group-gated too (`web_tools`), so it belongs next to the
//! groups rather than in any one consumer. Both processes that spawn a
//! `claude` — the harness itself and the subagent daemon — call the same
//! function, so a subagent can never be handed a built-in its parent does
//! not have — with one deliberate exception: [`subagent_builtin_tools_arg`]
//! additionally grants a **subagent** Claude's own `Bash` when the parent
//! holds [`ToolGroup::Execution`]. The parent never gets built-in `Bash`
//! itself (it has the out-of-process `bash` MCP server instead); the
//! capability transfers to a subagent, not the mechanism.
use std::str::FromStr;
//! The set lives in the swarm's `agent-permissions` record
//! (`swarm_queue_client::agent_permissions`), one list per agent. Each member
//! is enforced where it applies: the MCP-tool and built-in-tool members by
//! the processes that spawn `claude` (the agent harness and the subagent
//! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An
//! agent the swarm sets nothing for holds [`Permission::default_for`].
//!
//! It also resolves the *built-in* claude tool list ([`builtin_tools_for`]):
//! that set is permission-gated too (`web_tools`), so it belongs here rather
//! than in any one consumer. Both processes that spawn a `claude` call the
//! same function, so a subagent can never be handed a built-in its parent
//! does not have — with one deliberate exception:
//! [`subagent_builtin_tools_for`] additionally grants a **subagent** Claude's
//! own `Bash` when the parent holds [`Permission::Execution`]. The parent
//! never gets built-in `Bash` itself (it has the out-of-process `bash` MCP
//! server instead); the permission transfers to a subagent, not the
//! mechanism.
use serde::{Deserialize, Serialize};
/// Env var the meta renderer writes with a comma-separated list of
/// [`ToolGroup`] `snake_case` names (e.g. `"messaging,inbox,meta"`). Absent
/// or unparseable means [`ToolGroup::AGENT_DEFAULT`]. See
/// `docs/process/conventions.md::Tool groups`.
pub const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
/// Built-in claude tools present in every session regardless of tool
/// groups. Anything not in this list (and not added by a group's
/// [`ToolGroup::builtin_tools`]) literally doesn't exist in the session —
/// Built-in claude tools present in every session regardless of permissions.
/// Anything not in this list (and not added by a permission's
/// [`Permission::builtin_tools`]) literally doesn't exist in the session —
/// this is the `--tools` list, which holds even under
/// `--dangerously-skip-permissions`.
///
/// Web egress (`WebFetch`/`WebSearch`) is group-gated (`web_tools`) and so
/// Web egress (`WebFetch`/`WebSearch`) is permission-gated (`web_tools`) and so
/// lives there, off by default. Nested agents (`Task`/`Agent`) are
/// intentionally omitted. `Bash` is disallowed — shell execution goes
/// through `mcp__bash__run` (background tasks with structured output via
@ -46,23 +40,24 @@ pub const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
/// matches the task at hand.
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Skill", "Write"];
/// The tool groups in effect for this process, from [`TOOL_GROUPS_ENV`].
/// The built-in claude tools `perms` resolve to: [`ALLOWED_BUILTIN_TOOLS`]
/// plus each permission's own built-ins, de-duplicated, base set first.
///
/// Absent, blank, or naming nothing recognisable all fall back to
/// [`ToolGroup::AGENT_DEFAULT`] — the var is written per agent by the meta
/// renderer, so "unset" is an ordinary state, not an error.
/// ⚠️ The result is a claude `--tools` value once joined, and must **never be
/// empty** — not because of what an empty value does, but because we don't
/// know. Our own measurement and the installed `claude --help` disagree about
/// whether `--tools ""` means "no tools" or parses as the flag being unset,
/// and the answer is a property of whichever claude release is installed.
/// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case from arising.
///
/// ⚠️ This governs built-ins only. `mcp__*` tools are not filtered by
/// `--tools` at all; they are governed by `--strict-mcp-config` plus
/// whatever the caller renders into `--mcp-config`.
#[must_use]
pub fn effective_tool_groups() -> Vec<ToolGroup> {
ToolGroup::parse_list(&std::env::var(TOOL_GROUPS_ENV).unwrap_or_default())
}
/// The built-in claude tools `groups` resolve to: [`ALLOWED_BUILTIN_TOOLS`]
/// plus each group's own built-ins, de-duplicated, base set first.
#[must_use]
pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
pub fn builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> {
let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec();
for group in groups {
for tool in group.builtin_tools() {
for perm in perms {
for tool in perm.builtin_tools() {
if !tools.contains(tool) {
tools.push(tool);
}
@ -71,205 +66,131 @@ pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
tools
}
/// The value for claude's `--tools` flag: which built-in tools exist in this
/// session at all.
///
/// ⚠️ **Never emit this as an empty string** — not because of what an empty
/// value does, but because we don't know. Our own measurement and the
/// installed `claude --help` disagree about whether `--tools ""` means "no
/// tools" or parses as the flag being unset, and the answer is a property of
/// whichever claude release is installed, not of this code. So an empty
/// value is refused rather than relied on: the rule holds under either
/// reading, and no caller has to know which one is true today.
/// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case from
/// arising in the first place.
///
/// ⚠️ This governs built-ins only. `mcp__*` tools are not filtered by
/// `--tools` at all; they are governed by `--strict-mcp-config` plus
/// whatever the caller renders into `--mcp-config`.
#[must_use]
pub fn builtin_tools_arg() -> String {
builtin_tools_for(&effective_tool_groups()).join(",")
}
/// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `groups`
/// contains [`ToolGroup::Execution`] — the **subagent**-only `--tools`
/// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `perms`
/// contains [`Permission::Execution`] — the **subagent**-only `--tools`
/// resolution.
///
/// `Execution` already grants shell execution to the parent agent, via the
/// out-of-process `bash` MCP server (`mcp__bash__run`/`status`/`kill` —
/// see the variant's own doc comment). A subagent has no MCP server at all
/// by default (`docs/tools/subagent.md`), so there is no `mcp__bash__*` for
/// it to inherit that capability through; it gets Claude's own `Bash`
/// instead. The capability transfers from the parent, the mechanism it
/// arrives by does not have to match.
/// it to inherit that permission through; it gets Claude's own `Bash`
/// instead.
///
/// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`ToolGroup::builtin_tools`]:
/// those are shared with the harness's own `--tools` (`hive-agent`'s
/// `mcp_config::allowed_tools_arg`), and the main agent must never gain
/// built-in `Bash` no matter which groups it holds — only a *subagent*
/// spawned by an `Execution`-holding parent does.
/// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`Permission::builtin_tools`]:
/// those are shared with the harness's own `--tools`, and the main agent must
/// never gain built-in `Bash` no matter which permissions it holds — only a
/// *subagent* spawned by an `Execution`-holding parent does.
#[must_use]
pub fn subagent_builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
let mut tools = builtin_tools_for(groups);
if groups.contains(&ToolGroup::Execution) {
pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> {
let mut tools = builtin_tools_for(perms);
if perms.contains(&Permission::Execution) {
tools.push("Bash");
}
tools
}
/// The value for a **subagent's** `--tools` flag: [`subagent_builtin_tools_for`]
/// resolved against [`effective_tool_groups`]. See that function for why this
/// differs from [`builtin_tools_arg`].
#[must_use]
pub fn subagent_builtin_tools_arg() -> String {
subagent_builtin_tools_for(&effective_tool_groups()).join(",")
}
/// Named group of MCP tools an agent may be granted. The harness reads
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
/// `snake_case` group names written by the meta renderer from per-agent
/// config) and expands it to the matching tool names for `--allowedTools`.
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
/// See `docs/process/conventions.md::Tool groups`.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::IntoStaticStr,
strum::EnumString,
)]
/// One thing an agent may be permitted. Stored by its `snake_case` name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ToolGroup {
pub enum Permission {
/// `send`, `recv`, `ack_until`
Messaging,
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
Meta,
/// `get_loose_ends`, `cancel_loose_end`, `remind`
Inbox,
/// Gates no tool today — `list_containers`, its only member, was
/// removed with no replacement. Kept so existing
/// `meta/tool-groups.json` grants still parse; `tools()` returns
/// `&[]`.
Lifecycle,
/// Gates no tool today — `create_repo`, its only member, was removed
/// with no replacement. Kept so existing `meta/tool-groups.json`
/// grants still parse; `tools()` returns `&[]`.
Forge,
/// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/
/// `kill`) is rendered into the agent's config at all — see
/// `extra_server_required_group` in `hive-agent/src/mcp_config.rs`.
/// `extra_server_required_permission` in `hive-agent/src/mcp_config.rs`.
/// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*`
/// allowlist entry, the gate lives at config-render time instead.
Execution,
/// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and
/// `WebSearch` (search the web). Both are omitted from `--tools` by
/// default; adding this group to an agent enables them in the session
/// and in `--allowedTools` so they run without a confirmation prompt.
/// Does not gate any MCP tools — `tools()` returns `&[]`.
/// `WebSearch` (search the web). Both are omitted from `--tools`
/// without it; holding it enables them in the session and in
/// `--allowedTools` so they run without a confirmation prompt.
WebTools,
}
impl ToolGroup {
/// Parse a comma-separated [`TOOL_GROUPS_ENV`] value. Tokens are trimmed
/// and lowercased; an unrecognised one is warned about and skipped
/// rather than failing the whole list, so one stale name in an agent's
/// config can't take its whole tool surface away.
/// Agent may manage *any* agent in the hive. Concretely, hive-c0re
/// bind-mounts every agent's state dir (RW) and config dir (RO) into the
/// holder's container, plus `/applied` and `/meta` read-only, so the
/// holder can recover an agent that is down. See
/// `lifecycle::host_config::set_nspawn_flags`.
///
/// Returns [`AGENT_DEFAULT`](Self::AGENT_DEFAULT) when `raw` is blank or
/// names nothing recognisable — the same fallback either way, because
/// "the operator granted no groups" and "the var is missing" are not
/// distinguishable here and neither should mean "no tools".
#[must_use]
pub fn parse_list(raw: &str) -> Vec<Self> {
let mut groups = Vec::new();
for token in raw.split(',') {
let token = token.trim().to_ascii_lowercase();
if token.is_empty() {
continue;
}
match Self::from_str(&token) {
Ok(group) => groups.push(group),
Err(_) => {
tracing::warn!(token = %token, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
}
}
}
if groups.is_empty() {
if !raw.trim().is_empty() {
tracing::warn!(
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
falling back to AGENT_DEFAULT"
);
}
return Self::AGENT_DEFAULT.to_vec();
}
groups
/// The name is historical, from when "the root agent" was the widest
/// thing to manage. Kept under that spelling because every stored grant
/// carries it.
ManageRootAgent,
}
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
/// Returns `&[]` for `WebTools` (it enables Claude built-in tools, not MCP
/// tools — see `builtin_tools()`) and for `Lifecycle` / `Forge` (their
/// tools were removed); see each variant's doc comment.
impl Permission {
/// Every permission in a stable order.
pub const ALL: &'static [Self] = &[
Self::Messaging,
Self::Meta,
Self::Inbox,
Self::Execution,
Self::WebTools,
Self::ManageRootAgent,
];
/// What an agent the swarm sets nothing for holds.
pub const DEFAULT: &'static [Self] =
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
/// What `agent` holds when the swarm sets nothing for it: [`Self::DEFAULT`],
/// plus [`Self::ManageRootAgent`] for the root agent
/// ([`crate::manager::MANAGER_AGENT`]), which recovers the others.
#[must_use]
pub fn default_for(agent: &str) -> Vec<Self> {
let mut perms = Self::DEFAULT.to_vec();
if agent == crate::manager::MANAGER_AGENT {
perms.push(Self::ManageRootAgent);
}
perms
}
/// The MCP tool names (without the `mcp__hyperhive__` prefix) this
/// permission grants. Empty for the permissions that grant something
/// else; see each variant's doc comment.
#[must_use]
pub fn tools(self) -> &'static [&'static str] {
match self {
Self::Messaging => &["send", "recv", "ack_until"],
Self::Meta => &["get_agent_meta"],
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
// All four empty, for different reasons — see each
// variant's own doc comment above. `Execution` grants the
// out-of-process `bash` MCP server
// (`mcp__bash__run`/`status`/`kill`), gated at config-render
// time by `extra_server_required_group` in
// `hive-agent/src/mcp_config.rs`, not by this list — an
// out-of-process server has no later enforcement point, so
// that gate is the actual security boundary. `WebTools`
// grants Claude built-in tools, not MCP ones; see
// `builtin_tools()`. `Lifecycle` and `Forge` each listed
// exactly one tool — `list_containers` and `create_repo`
// respectively — and both tools were removed outright; the
// variants stay so existing grants parse.
Self::Lifecycle | Self::Forge | Self::Execution | Self::WebTools => &[],
// `Execution` grants the out-of-process `bash` MCP server,
// gated at config-render time in `hive-agent/src/mcp_config.rs`
// — an out-of-process server has no later enforcement point, so
// that gate is the actual security boundary. `WebTools` grants
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
// bind mounts.
Self::Execution | Self::WebTools | Self::ManageRootAgent => &[],
}
}
/// MCP tools that are always exposed regardless of which tool groups an
/// agent is granted. `set_status` lives here because the operator
/// MCP tools that are always exposed regardless of which permissions an
/// agent holds. `set_status` lives here because the operator
/// dashboard depends on every agent being able to report its status
/// chip — gating it behind a group would let a misconfigured agent go
/// dark on the dashboard. The server-side `SetStatus` handler has no
/// tool-group check either (only length validation), so listing it here
/// keeps the `--allowedTools` list honest with that reality.
/// chip — gating it would let a misconfigured agent go dark on the
/// dashboard. The server-side `SetStatus` handler has no permission
/// check either (only length validation), so listing it here keeps the
/// `--allowedTools` list honest with that reality.
///
/// `compact` lives here too: it's pure self-management (no cross-agent
/// effect, no privilege), gated server-side on context usage rather
/// than on tool groups, and every agent should be able to reach for it
/// regardless of which optional groups it's been granted — same
/// reasoning as `set_status`.
/// effect, no privilege), gated server-side on context usage, and every
/// agent should be able to reach for it — same reasoning as `set_status`.
///
/// `mark_todos_done` too (a critical bug every agent hit): it was
/// declared as a `#[tool]` fn but never added to *any*
/// group's [`tools`](Self::tools), including `Inbox`, so no agent could
/// ever get it into `--allowedTools` and every call prompted for
/// approval it can't get. Todos are pushed to an agent independent of
/// whether it holds `Inbox` (that group only gates
/// `get_loose_ends`/`cancel_loose_end`/`remind`), so an agent without
/// `Inbox` could accumulate todos it can never clear — same
/// "every agent needs this regardless of optional groups" shape as
/// `set_status`/`compact`, not a narrower `Inbox`-only fix.
/// `mark_todos_done` too: todos are pushed to an agent whether or not it
/// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/
/// `remind`), so an agent without `Inbox` must still be able to clear
/// them.
pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
&["set_status", "compact", "mark_todos_done"];
/// The Claude built-in tool names enabled by this group. Only
/// `WebTools` returns a non-empty slice; all other groups return `&[]`
/// (they control MCP tools via `tools()` instead).
/// The Claude built-in tool names this permission enables. Only
/// `WebTools` returns a non-empty slice.
#[must_use]
pub fn builtin_tools(self) -> &'static [&'static str] {
match self {
@ -277,163 +198,91 @@ impl ToolGroup {
_ => &[],
}
}
/// Default tool groups for an agent harness. Used when `HIVE_TOOL_GROUPS` is unset.
pub const AGENT_DEFAULT: &'static [Self] =
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
/// Convenience preset for a fully-privileged agent (all groups).
/// Use this as a starting point in `tool-groups.json` for root/manager agents.
pub const MANAGER_DEFAULT: &'static [Self] = &[
Self::Messaging,
Self::Meta,
Self::Inbox,
Self::Lifecycle,
Self::Execution,
];
/// Every known tool group in a stable order. Use this to enumerate
/// columns in the capabilities UI or any other place that needs the
/// full list without hard-coding it at the call site.
pub const ALL: &'static [Self] = &[
Self::Messaging,
Self::Meta,
Self::Inbox,
Self::Lifecycle,
Self::Forge,
Self::Execution,
Self::WebTools,
];
/// Short human-readable description suitable for a tooltip or help text.
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::Messaging => "send, recv, ack_until — core agent communication",
Self::Meta => {
"get_agent_meta — identity introspection (set_status is always available)"
}
Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling",
Self::Lifecycle => "no tools — vestigial since list_containers was removed",
Self::Forge => "no tools — vestigial since create_repo was removed",
Self::Execution => {
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
}
Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools",
}
}
}
/// Per-agent capability grants: the `capabilities` field of the swarm's
/// `agent-permissions` record (`swarm_queue_client::agent_permissions`), set
/// with `swarmctl agent permissions set`. Capabilities control system-level
/// access hive-c0re enforces, through the container's bind mounts; they are
/// orthogonal to tool groups (which control which MCP tools the harness
/// exposes to claude). See `docs/process/conventions.md::Capabilities`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Capability {
/// Agent may manage *any* agent in the hive, not just its own
/// children. Concretely, hive-c0re bind-mounts every agent's state
/// dir (RW) and config dir (RO) into the holder's container, plus
/// `/applied` and `/meta` read-only, so the holder can recover an
/// agent that is down. See `lifecycle::host_config::set_nspawn_flags`.
///
/// The name is historical: it was minted when the hive had a parent/
/// child topology and "manage the root agent" was the widest grant
/// there was. With the hierarchy removed there is no privileged root
/// to single out, so the same grant now means "manage any agent".
/// Kept under the old spelling deliberately — renaming it would turn
/// every `manage_root_agent` already stored in a swarm record into a
/// name the hive can no longer decode.
ManageRootAgent,
}
impl Capability {
/// Every known capability in a stable order.
pub const ALL: &'static [Self] = &[Self::ManageRootAgent];
}
#[cfg(test)]
mod tests {
use super::*;
/// The env-var spellings the meta renderer actually writes must round-trip
/// back to groups. `strum::EnumString` and serde's `rename_all` are two
/// independent derives over the same names; this pins them together, since
/// the renderer writes what serde would produce and the parse is strum's.
/// The stored names are the `snake_case` spellings the old tool-group
/// and capability files used, so an imported grant decodes.
#[test]
fn parse_list_accepts_the_snake_case_names_the_renderer_writes() {
fn stored_names_are_snake_case() {
let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect();
assert_eq!(
ToolGroup::parse_list("messaging, Meta ,web_tools"),
vec![ToolGroup::Messaging, ToolGroup::Meta, ToolGroup::WebTools]
names,
[
"messaging",
"meta",
"inbox",
"execution",
"web_tools",
"manage_root_agent"
]
);
for group in ToolGroup::ALL {
let name: &'static str = (*group).into();
assert_eq!(ToolGroup::parse_list(name), vec![*group], "{name}");
for perm in Permission::ALL {
let json = serde_json::to_string(perm).unwrap();
assert_eq!(json, format!("\"{}\"", <&str>::from(*perm)));
}
assert!(serde_json::from_str::<Permission>("\"lifecycle\"").is_err());
}
/// Blank, absent and all-garbage are the same state as far as the tool
/// surface is concerned: the default groups, never an empty list.
#[test]
fn parse_list_falls_back_to_the_agent_default_rather_than_nothing() {
for raw in ["", " ", ",,", "not_a_group,also_not"] {
fn only_the_root_agent_defaults_to_managing_the_others() {
assert_eq!(
ToolGroup::parse_list(raw),
ToolGroup::AGENT_DEFAULT.to_vec(),
"{raw:?}"
Permission::default_for("atlas"),
Permission::DEFAULT.to_vec()
);
}
let ruth = Permission::default_for(crate::manager::MANAGER_AGENT);
assert!(ruth.contains(&Permission::ManageRootAgent));
assert_eq!(&ruth[..Permission::DEFAULT.len()], Permission::DEFAULT);
}
/// Adding groups only ever adds built-ins — the base set is in every
/// resolution, and no group can remove one. Both `--tools` callers (the
/// Adding permissions only ever adds built-ins — the base set is in every
/// resolution, and no permission can remove one. Both `--tools` callers (the
/// harness and the subagent daemon) rely on this to reason about "the
/// parent's set" without re-deriving it.
#[test]
fn builtin_tools_are_the_base_set_plus_whatever_the_groups_add() {
fn builtin_tools_are_the_base_set_plus_whatever_the_permissions_add() {
assert_eq!(
builtin_tools_for(&[]),
ALLOWED_BUILTIN_TOOLS.to_vec(),
"no groups resolves to exactly the base set"
"no permissions resolves to exactly the base set"
);
assert_eq!(
builtin_tools_for(ToolGroup::AGENT_DEFAULT),
builtin_tools_for(Permission::DEFAULT),
ALLOWED_BUILTIN_TOOLS.to_vec(),
"the default groups add no built-ins"
"the default permissions add no built-ins"
);
for group in ToolGroup::ALL {
let resolved = builtin_tools_for(&[*group]);
for perm in Permission::ALL {
let resolved = builtin_tools_for(&[*perm]);
for base in ALLOWED_BUILTIN_TOOLS {
assert!(resolved.contains(base), "{group:?} dropped {base}");
assert!(resolved.contains(base), "{perm:?} dropped {base}");
}
}
}
/// `web_tools` is the one group that widens the built-in set, and it is
/// `web_tools` is the one permission that widens the built-in set, and it is
/// opt-in. A subagent inherits this resolution rather than a list of its
/// own precisely so it cannot get web egress from a parent without the
/// group.
/// permission.
#[test]
fn web_egress_is_present_only_with_the_web_tools_group() {
let with = builtin_tools_for(&[ToolGroup::WebTools]);
fn web_egress_is_present_only_with_the_web_tools_permission() {
let with = builtin_tools_for(&[Permission::WebTools]);
assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch"));
let without = builtin_tools_for(ToolGroup::MANAGER_DEFAULT);
let without = builtin_tools_for(Permission::DEFAULT);
assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch"));
}
/// No resolution may produce an empty `--tools` value. What an empty one
/// means is disputed and release-dependent (see [`builtin_tools_arg`]),
/// means is disputed and release-dependent (see [`builtin_tools_for`]),
/// so this pins the rule that makes the question moot rather than any
/// answer to it.
#[test]
fn no_group_combination_resolves_to_an_empty_tools_arg() {
fn no_permission_combination_resolves_to_an_empty_tools_arg() {
assert!(!ALLOWED_BUILTIN_TOOLS.is_empty());
assert!(!builtin_tools_for(&[]).is_empty());
assert!(!builtin_tools_for(ToolGroup::ALL).is_empty());
assert!(!builtin_tools_arg().is_empty());
assert!(!builtin_tools_for(Permission::ALL).is_empty());
}
/// Built-ins are not MCP tools and the two lists must not be mixed: a
@ -441,7 +290,7 @@ mod tests {
/// does not filter the MCP surface at all.
#[test]
fn the_builtin_list_names_no_mcp_tools() {
for tool in builtin_tools_for(ToolGroup::ALL) {
for tool in builtin_tools_for(Permission::ALL) {
assert!(!tool.contains("__"), "{tool} looks like an MCP tool name");
}
}
@ -452,7 +301,7 @@ mod tests {
/// inherit that through, so it gets Claude's built-in tool instead.
#[test]
fn subagent_gets_bash_when_parent_has_execution() {
let tools = subagent_builtin_tools_for(&[ToolGroup::Execution]);
let tools = subagent_builtin_tools_for(&[Permission::Execution]);
assert!(tools.contains(&"Bash"));
}
@ -461,24 +310,27 @@ mod tests {
/// grants it unconditionally would go unnoticed.
#[test]
fn subagent_gets_no_bash_without_execution() {
for groups in [&[][..], &[ToolGroup::Messaging, ToolGroup::WebTools][..]] {
let tools = subagent_builtin_tools_for(groups);
assert!(!tools.contains(&"Bash"), "{groups:?} must not grant Bash");
for permissions in [&[][..], &[Permission::Messaging, Permission::WebTools][..]] {
let tools = subagent_builtin_tools_for(permissions);
assert!(
!tools.contains(&"Bash"),
"{permissions:?} must not grant Bash"
);
}
}
/// The parent (harness) resolver is untouched: `builtin_tools_for` /
/// `builtin_tools_arg` — what `hive-agent`'s own `--tools` and
/// The parent (harness) resolver is untouched: `builtin_tools_for` —
/// what `hive-agent`'s own `--tools` and
/// `--allowedTools` are built from — never produce `Bash`, with or
/// without `Execution`. Only the subagent-specific resolver above adds
/// it; the operator's ruling was explicit that the main agent does not.
#[test]
fn parent_resolver_never_gains_bash() {
for groups in [&[][..], &[ToolGroup::Execution][..], ToolGroup::ALL] {
let tools = builtin_tools_for(groups);
for permissions in [&[][..], &[Permission::Execution][..], Permission::ALL] {
let tools = builtin_tools_for(permissions);
assert!(
!tools.contains(&"Bash"),
"{groups:?} must not grant Bash to the parent"
"{permissions:?} must not grant Bash to the parent"
);
}
}

View file

@ -16,7 +16,7 @@ clap.workspace = true
hive-agent-sock.workspace = true
hive-claude.workspace = true
hive-runtime.workspace = true
# `permissions::builtin_tools_arg` — the same `--tools` resolution the parent
# `permissions::subagent_builtin_tools_for` — the same `--tools` resolution the parent
# harness spawns its own claude with, so a subagent's built-in surface is its
# parent's rather than a second list that drifts. See `session::build_config`.
# Also `term_msg`/`stream_enrich`, the rows `swarm_term` publishes.
@ -30,7 +30,10 @@ serde.workspace = true
serde_json.workspace = true
# `subagent_term`: the subject each subagent's rows are published on.
# `agent-inference`: this agent's `subagent_models`, read with one direct get.
swarm-queue-client = { workspace = true, features = ["agent-inference"] }
swarm-queue-client = { workspace = true, features = [
"agent-inference",
"agent-permissions",
] }
# The agent's own queue credential, read under its store identity.
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -98,7 +98,7 @@ use hive_claude::{Attach, Claude, Config, SessionStore};
use hive_runtime::{
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
};
use hive_sh4re::permissions::ToolGroup;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::AgentInference;
use tokio::sync::oneshot;
@ -552,12 +552,16 @@ impl State {
command
}
/// Read the swarm's key once, refuse a `model` outside the list in effect
/// for this agent (see `check_model`), and return the subagent cap in
/// effect (see `subagent_cap`).
/// Read the swarm's keys once: refresh this agent's permissions (the
/// tools the subagent about to run gets), refuse a `model` outside the list
/// in effect for this agent (see `check_model`), and return the subagent
/// cap in effect (see `subagent_cap`).
async fn swarm_limits(&self, model: Option<&str>) -> anyhow::Result<Option<u32>> {
let read = match &self.models {
Some(reader) => Some(reader.read().await),
Some(reader) => {
reader.refresh_permissions().await;
Some(reader.read().await)
}
None => None,
};
let agent = self
@ -1051,7 +1055,7 @@ fn validate_name(name: &str) -> anyhow::Result<()> {
/// Env var carrying the nix-configured `services.hyperhive.agent.availableModels` as a
/// comma-separated list, forwarded onto this daemon's unit by
/// `nix/agent-modules/mcp.nix` the same way `HIVE_TOOL_GROUPS` is.
/// `nix/agent-modules/mcp.nix` from the harness unit.
const AVAILABLE_MODELS_ENV: &str = "HIVE_AVAILABLE_MODELS";
/// The models this daemon may spawn a subagent on, from
@ -1136,7 +1140,7 @@ fn check_model(model: Option<&str>, list: &ModelList) -> anyhow::Result<()> {
"the swarm's subagent_models for this agent",
),
ModelList::Local(None) => return Ok(()),
// Same split/trim/skip-empty rules as `ToolGroup::parse_list`.
// Split, trim, skip empty tokens.
ModelList::Local(Some(env)) => (
env.split(',')
.map(str::trim)
@ -1186,7 +1190,8 @@ fn subagent_otel_attrs(name: &str) -> String {
/// gates covering one half each, neither substituting for the other:
/// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly
/// what [`crate::mcp_config::build`] renders), `--tools` over **built-in**
/// ones, via [`hive_sh4re::permissions::subagent_builtin_tools_arg`], which
/// ones, via [`hive_sh4re::permissions::subagent_builtin_tools_for`] over the
/// agent's permissions ([`crate::swarm_models::permissions_now`]), which
/// documents its one `Bash` exception. `--tools` does not filter `mcp__*`,
/// so the signal tools are unnamed in it (`docs/tools/subagent.md`).
///
@ -1208,10 +1213,13 @@ fn build_config(
dir: Option<&str>,
signal_url: Option<&str>,
) -> Config {
let tools = hive_sh4re::permissions::subagent_builtin_tools_arg();
let tools = hive_sh4re::permissions::subagent_builtin_tools_for(
&crate::swarm_models::permissions_now(),
)
.join(",");
// Refuse to spawn on an empty `--tools` rather than reason about what it
// would do: what an empty value means is disputed and depends on the
// installed claude release (see `builtin_tools_arg`). Nothing here wants
// installed claude release (see `builtin_tools_for`). Nothing here wants
// "a subagent with no built-ins" anyway, so an empty resolution is a bug
// either way, and failing on it is safe under every reading.
assert!(
@ -2063,9 +2071,9 @@ fn acp_runtime(
sessions: &Path,
name: &str,
) -> AcpRuntime<hive_claude::NeverCompact> {
let groups = hive_sh4re::permissions::effective_tool_groups();
let perms = crate::swarm_models::permissions_now();
let permit: PermissionPolicy =
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &groups));
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &perms));
AcpRuntime::new(
command,
acp_session_file(sessions, name),
@ -2079,15 +2087,15 @@ fn acp_runtime(
///
/// - `other`-kind calls to tools of the MCP servers the session was handed;
/// - the file tools: `read`, `edit`, `search`;
/// - `fetch` with the `web_tools` group;
/// - `execute` with the `execution` group, as a claude subagent gets `Bash`
/// - `fetch` with the `web_tools` permission;
/// - `execute` with the `execution` permission, as a claude subagent gets `Bash`
/// ([`hive_sh4re::permissions::subagent_builtin_tools_for`]).
fn acp_permits(ask: &PermissionAsk<'_>, groups: &[ToolGroup]) -> bool {
fn acp_permits(ask: &PermissionAsk<'_>, perms: &[Permission]) -> bool {
match ask.kind {
"other" => ask.mcp_server.is_some(),
"read" | "edit" | "search" => true,
"fetch" => groups.contains(&ToolGroup::WebTools),
"execute" => groups.contains(&ToolGroup::Execution),
"fetch" => perms.contains(&Permission::WebTools),
"execute" => perms.contains(&Permission::Execution),
_ => false,
}
}
@ -2984,15 +2992,13 @@ mod tests {
/// parent actually licenses rather than against a restatement of it that
/// could drift.
///
/// ⚠️ **Scope: this catches a code divergence, not an environment one.**
/// Both sides resolve in one process off one `HIVE_TOOL_GROUPS`, so it
/// cannot see the two real units disagreeing about that var — which they
/// did until `nix/agent-modules/mcp.nix` forwarded it onto this daemon.
/// Nothing in a unit test reaches that; it is a deployment property.
/// ⚠️ **Scope: this catches a code divergence, not a read one.** Both
/// sides resolve off one permission set here; that the parent and this
/// daemon read the same key is a deployment property.
#[test]
fn a_subagent_gets_no_builtin_its_parent_lacks() {
let allowed = hive_sh4re::permissions::subagent_builtin_tools_for(
&hive_sh4re::permissions::effective_tool_groups(),
&crate::swarm_models::permissions_now(),
);
let config = build_config("n", None, None, None, None, None);
for tool in spawned_tools(&config).split(',') {
@ -5412,8 +5418,8 @@ echo '{"type":"result","subtype":"success","is_error":false}'
kind,
mcp_server: None,
};
let none: &[ToolGroup] = &[];
let both = &[ToolGroup::WebTools, ToolGroup::Execution];
let none: &[Permission] = &[];
let both = &[Permission::WebTools, Permission::Execution];
for kind in ["read", "edit", "search"] {
assert!(acp_permits(&ask(kind), none), "{kind}");
}

View file

@ -1,11 +1,13 @@
//! Reads the subagent settings the swarm sets for this agent.
//! Reads the subagent settings and permissions the swarm sets for this agent.
//!
//! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
//! a `start` or `continue` naming a `model` is checked against, in place of
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
//! `max_subagents`, when set, caps how many subagents run at once
//! (`crate::session::State::reserve_under`).
//! (`crate::session::State::reserve_under`). The agent's permissions, under
//! its key in `agent-permissions`, decide a new subagent's tools
//! ([`permissions_now`]).
//!
//! **Read on every `start` and `continue`**, never cached, with the one subject the
//! agent's own queue credential is granted in that bucket
@ -23,10 +25,27 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
use swarm_queue_client::agent_permissions;
use crate::swarm_term::{Target, connect};
/// The last permission set [`Reader::refresh_permissions`] read; `None` until
/// one succeeds.
static LAST_PERMISSIONS: std::sync::RwLock<Option<Vec<Permission>>> = std::sync::RwLock::new(None);
/// The permissions this agent holds now, for the subagent being spawned: the
/// last set read, or [`Permission::DEFAULT`] before any read succeeds.
#[must_use]
pub fn permissions_now() -> Vec<Permission> {
LAST_PERMISSIONS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.unwrap_or_else(|| Permission::DEFAULT.to_vec())
}
/// How long after a failed connect the task tries again.
const RETRY_AFTER: Duration = Duration::from_mins(1);
@ -126,6 +145,46 @@ impl Reader {
}
}
}
/// Read the permissions the swarm grants this agent and keep them for
/// [`permissions_now`]: its list, or the default for an unset key. A read
/// that fails keeps the last value.
pub async fn refresh_permissions(&self) {
let read = match &self.source {
Source::Queue { agent, client } => read_permissions(agent, client).await,
#[cfg(test)]
Source::Fixed { .. } => None,
};
if let Some(perms) = read {
*LAST_PERMISSIONS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(perms);
}
}
}
async fn read_permissions(
agent: &str,
client: &OnceLock<async_nats::Client>,
) -> Option<Vec<Permission>> {
let client = client.get()?;
swarm_queue_client::ensure_connected(client).ok()?;
match tokio::time::timeout(READ_TIMEOUT, agent_permissions::read(client, agent)).await {
Ok(Ok(agent_permissions::Read::Current(record))) => Some(
record
.permissions
.unwrap_or_else(|| Permission::default_for(agent)),
),
Ok(Ok(agent_permissions::Read::NoBucket)) => None,
Ok(Err(e)) => {
tracing::warn!(error = %e, "subagent permissions: reading this agent's key failed");
None
}
Err(_) => {
tracing::warn!("subagent permissions: no answer within {READ_TIMEOUT:?}");
None
}
}
}
async fn read_queue(agent: &str, client: &OnceLock<async_nats::Client>) -> Read {

View file

@ -382,17 +382,7 @@ in
# HIVE_AGENT_SOCKET (agent-service.nix).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info";
# This daemon resolves each subagent's `--tools` from the same
# tool groups the harness resolves its own session from, so that a
# subagent's built-in tools are its parent agent's and never wider.
# The meta renderer writes this var onto the harness unit alone, so
# forward it from there rather than re-deriving it: without it the
# daemon falls back to the default groups and an agent granted
# `web_tools` would spawn subagents that quietly lack web access.
# `null` when the agent has no groups declared, which systemd drops
# — the same "absent" the harness itself would see.
HIVE_TOOL_GROUPS = config.systemd.services.hive-agent.environment.HIVE_TOOL_GROUPS or null;
# The harness's runtime selection, forwarded the same way, so an ACP
# 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;

View file

@ -831,6 +831,11 @@ in
# Its own key of the `agent-inference` KV bucket
# (`swarm_queue_client::agent_inference`), on the same terms.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-inference.\$\$KV.agent-inference.{agent}"}"
# Its own key of the `agent-permissions` KV bucket
# (`swarm_queue_client::agent_permissions`), on the same terms:
# the harness and the subagent daemon read the tool permissions
# it holds.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-permissions.\$\$KV.agent-permissions.{agent}"}"
"--store-cert-role ${lib.escapeShellArg authCertRole}"
];
# The store identity arrives by `LoadCredential` and is named

View file

@ -312,7 +312,7 @@ let
# The whole per-agent grant, compared as a list rather than by infix: a
# wider subject added beside these (`$$JS.API.>`, another stream's name)
# would pass every presence check above.
name = "a verified agent's grant is exactly its own subjects, and its JetStream API subjects read its own schedule and inference keys";
name = "a verified agent's grant is exactly its own subjects, and its JetStream API subjects read its own schedule, inference and permissions keys";
ok =
let
args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart;
@ -332,6 +332,7 @@ let
"'$$SWARM.term.{agent}.sub.>'"
"'$$JS.API.DIRECT.GET.KV_agent-schedules.$$KV.agent-schedules.{agent}'"
"'$$JS.API.DIRECT.GET.KV_agent-inference.$$KV.agent-inference.{agent}'"
"'$$JS.API.DIRECT.GET.KV_agent-permissions.$$KV.agent-permissions.{agent}'"
];
}
{

View file

@ -66,7 +66,7 @@ sha2.workspace = true
# `forge/pull_diff.rs` diffs a PR's changed files itself, at a context width
# the forge's own `.diff` does not offer.
similar.workspace = true
# The `Capability` names `/api/agents/{name}/permissions` accepts, the same
# The `Permission` names `/api/agents/{name}/permissions` accepts, the same
# enum the hive enforces.
hive-sh4re.workspace = true
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo`

View file

@ -1,11 +1,12 @@
//! `/api/agents/{name}/permissions` — the capabilities the swarm grants one
//! `/api/agents/{name}/permissions` — the permission set the swarm grants one
//! agent — and the one-shot import of the grants a hive's meta repo held.
//!
//! Each agent's permissions are one value under its own key of
//! `agent-permissions` (`swarm_queue_client::agent_permissions`), which this
//! daemon alone writes. The hive running the agent reads the key and restarts
//! the agent when its capabilities change. A field left unset leaves the agent
//! on the hive's compiled-in default; an empty list grants nothing.
//! daemon alone writes. The agent's harness and subagent daemon apply the tool
//! permissions at their next turn or start; the hive running the agent
//! restarts it when `manage_root_agent` changes. An unset field leaves the
//! agent on the compiled-in default; an empty list grants nothing.
use std::collections::BTreeSet;
use std::sync::Arc;
@ -14,13 +15,13 @@ use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use futures_util::StreamExt as _;
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
use utoipa::ToSchema;
use crate::schedules::{live, queue, read_or_default, refused, write};
use crate::schedules::{live, queue, read_or_default, refused, write, write_revision};
use crate::wanted::WantedWriter;
use crate::{AppState, error_problem, wanted_error_status};
@ -29,39 +30,45 @@ use crate::{AppState, error_problem, wanted_error_status};
const MAX_ATTEMPTS: usize = 5;
/// One agent's permissions. A field left out is not set by the swarm, and the
/// hive's default applies; an empty list grants nothing.
/// compiled-in default applies; an empty list grants nothing.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct Permissions {
/// Capability names, `snake_case`: `manage_root_agent` mounts every agent's
/// state and config into this one for recovery.
/// Permission names, `snake_case`: `messaging`, `meta`, `inbox`,
/// `execution`, `web_tools`, `manage_root_agent`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Vec<String>>, example = json!(["manage_root_agent"]))]
capabilities: Option<Vec<Capability>>,
#[schema(value_type = Option<Vec<String>>, example = json!(["messaging", "meta", "inbox", "execution"]))]
permissions: Option<Vec<Permission>>,
}
impl Permissions {
/// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentPermissions, String> {
if let Some(caps) = &self.capabilities {
let mut seen = std::collections::HashSet::new();
for cap in caps {
if !seen.insert(*cap) {
let name: &str = (*cap).into();
return Err(format!("capability {name:?} is listed twice"));
}
}
if let Some(perms) = &self.permissions {
unique(perms)?;
}
Ok(AgentPermissions {
capabilities: self.capabilities,
permissions: self.permissions,
})
}
}
/// Refuse a list that names a permission twice.
pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for perm in perms {
if !seen.insert(*perm) {
let name: &str = (*perm).into();
return Err(format!("permission {name:?} is listed twice"));
}
}
Ok(())
}
impl From<AgentPermissions> for Permissions {
fn from(v: AgentPermissions) -> Self {
Self {
capabilities: v.capabilities,
permissions: v.permissions,
}
}
}
@ -116,8 +123,8 @@ pub(crate) async fn get_agent_permissions(
/// Set the permissions the swarm grants one agent.
///
/// The value replaces the stored one whole. The hive running the agent reads
/// it within 30 seconds and, when the agent's capabilities changed, restarts
/// it after its current turn.
/// it within 30 seconds and restarts the agent, after its current turn, when
/// `manage_root_agent` changed; the agent applies the rest at its next turn.
#[utoipa::path(
put,
path = "/api/agents/{name}/permissions",
@ -126,8 +133,8 @@ pub(crate) async fn get_agent_permissions(
responses(
(status = 200, description = "the permissions as stored", body = Permissions),
(status = 400, description = "not an identifier, not declared on any hive, or a \
capability listed twice (problem+json)", body = String),
(status = 422, description = "an unknown field or capability name"),
permission listed twice (problem+json)", body = String),
(status = 422, description = "an unknown field or permission name"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be written (problem+json)",
@ -199,6 +206,101 @@ pub(crate) async fn delete_agent_permissions(
Ok(StatusCode::NO_CONTENT)
}
/// Every permission name, and the set an agent the swarm sets nothing for
/// holds — so a client lists them without a copy of its own.
#[derive(Debug, Serialize, ToSchema)]
pub(crate) struct Catalogue {
#[schema(value_type = Vec<String>)]
all: Vec<Permission>,
#[schema(value_type = Vec<String>)]
default: Vec<Permission>,
}
#[utoipa::path(
get,
path = "/api/permissions",
responses((status = 200, description = "every permission name, and the default set",
body = Catalogue)),
tag = "agents"
)]
pub(crate) async fn get_permission_catalogue() -> Json<Catalogue> {
Json(Catalogue {
all: Permission::ALL.to_vec(),
default: Permission::DEFAULT.to_vec(),
})
}
/// The value `POST /api/agents` stores for `perms`, or why it is refused.
/// The agent is not declared on its hive yet, so the "declared by a hive"
/// check of [`put_agent_permissions`] does not apply.
pub(crate) fn at_create(perms: Vec<Permission>) -> Result<AgentPermissions, String> {
unique(&perms)?;
Ok(AgentPermissions {
permissions: Some(perms),
})
}
/// What [`store`] replaced, for [`restore`]; `None` when it wrote nothing.
pub(crate) type Replaced = Option<crate::schedules::Replaced<AgentPermissions>>;
/// Replace `agent`'s stored permissions with `value`.
pub(crate) async fn store(
client: &async_nats::Client,
agent: &str,
value: &AgentPermissions,
) -> Result<Replaced, ProblemDetails> {
let store = open_or_create(client).await?;
for _ in 0..MAX_ATTEMPTS {
let (current, revision, undecodable) = read_or_default::<AgentPermissions>(&store, agent)
.await
.map_err(|e| queue_problem(&e))?;
if current == *value && !undecodable {
tracing::info!(%agent, ?value, "set agent permissions at create");
return Ok(None);
}
// A deleted key reads as present at its marker's revision, so ask
// whether it holds a value before keeping one to put back.
let held = store
.get(agent)
.await
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?
.is_some();
if let Some(written) = write_revision(&store, agent, value, revision)
.await
.map_err(|e| queue_problem(&e))?
{
tracing::info!(%agent, ?value, "set agent permissions at create");
return Ok(Some(crate::schedules::Replaced::new(
held.then_some(current),
written,
)));
}
}
Err(queue_problem(&anyhow::anyhow!(
"gave up writing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
)))
}
/// Undo what [`store`] did, unless another write has landed since. Best
/// effort: a failure is logged, since the caller is already on its way to an
/// error of its own.
pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: Replaced) {
let Some(replaced) = replaced else { return };
let undone = match open_or_create(client).await {
Ok(store) => replaced.undo(&store, agent).await,
Err(e) => Err(anyhow::anyhow!("{e:?}")),
};
match undone {
Ok(true) => {}
Ok(false) => {
tracing::info!(%agent, "left the agent's permissions: another write landed since");
}
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "could not put back the agent's permissions");
}
}
}
/// Create the bucket as soon as the queue is connected, then answer every
/// hive's import. Creating it first means a hive that finds no bucket knows
/// the queue is not serving it, rather than that nothing is set.
@ -276,7 +378,7 @@ async fn answer_import(
}
}
/// Write each agent's imported capabilities under its key when it has none,
/// Write each agent's imported permissions under its key when it has none,
/// for the agents in `declared` only.
async fn import_into(
store: &async_nats::jetstream::kv::Store,
@ -284,13 +386,13 @@ async fn import_into(
import: Import,
) -> anyhow::Result<ImportReply> {
let (mut written, mut kept, mut skipped) = (Vec::new(), Vec::new(), Vec::new());
for (agent, caps) in import.capabilities {
for (agent, caps) in import.permissions {
if !declared.contains(&agent) {
skipped.push(agent);
continue;
}
let value = AgentPermissions {
capabilities: Some(caps),
permissions: Some(caps),
};
if write(store, &agent, &value, None).await? {
written.push(agent);
@ -314,7 +416,7 @@ mod tests {
use crate::test_nats::TestNats;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{Import, ImportReply};
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
@ -330,7 +432,7 @@ mod tests {
fn granted() -> Permissions {
Permissions {
capabilities: Some(vec![Capability::ManageRootAgent]),
permissions: Some(vec![Permission::ManageRootAgent]),
}
}
@ -368,17 +470,17 @@ mod tests {
);
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":["manage_root_agent"]}"#)
Some(r#"{"permissions":["manage_root_agent"]}"#)
);
assert_eq!(get(&state, "atlas").await, granted());
let none = Permissions {
capabilities: Some(Vec::new()),
permissions: Some(Vec::new()),
};
let _ = put(&state, "atlas", none.clone()).await.expect("set");
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":[]}"#)
Some(r#"{"permissions":[]}"#)
);
assert_eq!(
@ -418,11 +520,11 @@ mod tests {
.status()
}
/// Behind the real router: an unknown field or capability name is a 422,
/// Behind the real router: an unknown field or permission name is a 422,
/// a duplicate is a 400, and none of them changes what is stored.
#[tokio::test]
async fn a_bad_body_is_refused_and_the_grant_stays() {
const GRANTED: &str = r#"{"capabilities":["manage_root_agent"]}"#;
const GRANTED: &str = r#"{"permissions":["manage_root_agent"]}"#;
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
@ -431,16 +533,20 @@ mod tests {
for (body, status) in [
(
r#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#,
r#"{"permissions":["manage_root_agent"],"tool_groups":["meta"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(r#"{"capabilites":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
(r#"{"permisions":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
(
r#"{"capabilities":["fly_to_the_moon"]}"#,
r#"{"permissions":["lifecycle"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
r#"{"capabilities":["manage_root_agent","manage_root_agent"]}"#,
r#"{"permissions":["fly_to_the_moon"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
r#"{"permissions":["manage_root_agent","manage_root_agent"]}"#,
StatusCode::BAD_REQUEST,
),
] {
@ -466,15 +572,15 @@ mod tests {
.await
.expect("bucket");
store
.put("atlas", br#"{"capabilities":[]}"#.to_vec().into())
.put("atlas", br#"{"permissions":[]}"#.to_vec().into())
.await
.expect("seed");
let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
let import = Import {
capabilities: [
("atlas".to_owned(), vec![Capability::ManageRootAgent]),
("ruth".to_owned(), vec![Capability::ManageRootAgent]),
("iris".to_owned(), vec![Capability::ManageRootAgent]),
permissions: [
("atlas".to_owned(), vec![Permission::ManageRootAgent]),
("ruth".to_owned(), vec![Permission::ManageRootAgent]),
("iris".to_owned(), vec![Permission::ManageRootAgent]),
]
.into(),
};
@ -492,12 +598,76 @@ mod tests {
);
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":[]}"#)
Some(r#"{"permissions":[]}"#)
);
assert_eq!(
published(&client, "ruth").await.as_deref(),
Some(r#"{"capabilities":["manage_root_agent"]}"#)
Some(r#"{"permissions":["manage_root_agent"]}"#)
);
assert_eq!(published(&client, "iris").await, None);
}
/// `POST /api/agents` with `permissions` stores them under the new agent's
/// key in the same call; a list naming one twice is refused before
/// anything is stored or queued.
#[tokio::test]
async fn create_writes_the_permissions_it_is_given() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let create = |name: &str, permissions| {
crate::create_agent(
State(state.clone()),
axum::Json(crate::CreateAgentRequest {
name: name.to_owned(),
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions,
}),
)
};
let problem = create(
"iris",
Some(vec![Permission::Messaging, Permission::Messaging]),
)
.await
.expect_err("a duplicate is refused");
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
assert_eq!(published(&client, "iris").await, None);
let queued = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph()
.nodes()
.count();
assert_eq!(queued, 0, "nothing is queued for a refused create");
let _ = create("iris", Some(vec![Permission::Messaging]))
.await
.expect("created");
assert_eq!(
published(&client, "iris").await.as_deref(),
Some(r#"{"permissions":["messaging"]}"#)
);
// Control: no `permissions` writes no key, so the default applies.
let _ = create("damocles", None).await.expect("created");
assert_eq!(published(&client, "damocles").await, None);
}
#[tokio::test]
async fn the_catalogue_lists_every_permission_and_the_default() {
let json = serde_json::to_value(super::get_permission_catalogue().await.0).unwrap();
assert_eq!(
json["all"].as_array().map(Vec::len),
Some(Permission::ALL.len())
);
assert_eq!(
json["default"],
serde_json::json!(["messaging", "meta", "inbox", "execution"])
);
}
}

View file

@ -1742,6 +1742,11 @@ struct CreateAgentRequest {
/// /api/agents/{name}/inference` takes them. Absent as `limits`.
#[serde(default)]
inference: Option<inference::InferenceSettings>,
/// The agent's permissions, stored before the job is queued. Left out, the
/// agent holds the default set.
#[serde(default)]
#[schema(value_type = Option<Vec<String>>)]
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
}
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or
@ -1795,10 +1800,10 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, or `limits` or `inference` holds a value the matching `PUT` refuses (problem+json)", body = String),
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, or a permission is listed twice (problem+json)", body = String),
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, or an `effort` that is not one of the five levels"),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits` or `inference` is set and cannot be stored (problem+json)", body = String),
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference` or `permissions` is set and cannot be stored (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
),
tag = "agents"
@ -1813,15 +1818,7 @@ async fn create_agent(
// Settings are checked before anything is created, so a bad value stops
// the request rather than leaving an agent without them.
let limits = req
.limits
.map(agent_limits::Caps::into_stored)
.transpose()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
let inference = req
.inference
.map(inference::InferenceSettings::into_stored)
.transpose()
let settings = CreateSettings::parse(req.limits, req.inference, req.permissions)
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
// Two checks, and the second is the one that makes the field worth
@ -1885,7 +1882,7 @@ async fn create_agent(
}
// Checked after the roster, so a mistyped hive stays a 400 while the queue
// is down.
let settings_queue = if limits.is_some() || inference.is_some() {
let settings_queue = if settings.any() {
Some(schedules::queue(&state).map_err(schedules::refused)?)
} else {
None
@ -1916,7 +1913,14 @@ async fn create_agent(
// settings behind for the name.
let stored = match &settings_queue {
Some(client) => {
StoredSettings::store(client, &agent, limits.as_ref(), inference.as_ref()).await?
StoredSettings::store(
client,
&agent,
settings.limits.as_ref(),
settings.inference.as_ref(),
settings.permissions.as_ref(),
)
.await?
}
None => StoredSettings::default(),
};
@ -1948,27 +1952,67 @@ async fn create_agent(
}))
}
/// The settings a `POST /api/agents` body gives, as they are stored.
struct CreateSettings {
limits: Option<swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
}
impl CreateSettings {
/// Each setting given, or why its `PUT` would refuse the first bad one.
fn parse(
limits: Option<agent_limits::Caps>,
inference: Option<inference::InferenceSettings>,
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
) -> Result<Self, String> {
Ok(Self {
limits: limits.map(agent_limits::Caps::into_stored).transpose()?,
inference: inference
.map(inference::InferenceSettings::into_stored)
.transpose()?,
permissions: permissions.map(agent_permissions::at_create).transpose()?,
})
}
/// Whether any setting is given.
fn any(&self) -> bool {
self.limits.is_some() || self.inference.is_some() || self.permissions.is_some()
}
}
/// What `create_agent` replaced in the settings stores, to put back if the
/// creation is refused after the write.
#[derive(Default)]
struct StoredSettings {
limits: agent_limits::Replaced,
inference: inference::Replaced,
permissions: agent_permissions::Replaced,
}
impl StoredSettings {
/// Store whichever of `limits` and `inference` are given. A failure on
/// the second puts back the first.
/// Store whichever of `limits`, `permissions` and `inference` are given, in
/// that order. A failure on one puts back the ones stored before it.
async fn store(
client: &async_nats::Client,
agent: &str,
limits: Option<&swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
) -> Result<Self, problem_details::ProblemDetails> {
let mut stored = Self::default();
if let Some(limits) = limits {
stored.limits = agent_limits::store(client, agent, limits).await?;
}
if let Some(permissions) = permissions {
match agent_permissions::store(client, agent, permissions).await {
Ok(replaced) => stored.permissions = replaced,
Err(e) => {
stored.roll_back(client, agent).await;
return Err(e);
}
}
}
if let Some(inference) = inference {
match inference::store(client, agent, inference).await {
Ok(replaced) => stored.inference = replaced,
@ -1984,6 +2028,7 @@ impl StoredSettings {
async fn roll_back(self, client: &async_nats::Client, agent: &str) {
agent_limits::restore(client, agent, self.limits).await;
inference::restore(client, agent, self.inference).await;
agent_permissions::restore(client, agent, self.permissions).await;
}
}
@ -3510,6 +3555,7 @@ fn build_app(state: AppState) -> axum::Router {
agent_permissions::put_agent_permissions,
agent_permissions::delete_agent_permissions
))
.routes(routes!(agent_permissions::get_permission_catalogue))
.routes(routes!(get_config_prs))
.routes(routes!(create_agent))
.routes(routes!(mint_agent_forge_token))
@ -3771,6 +3817,7 @@ mod tests {
hive: "pr1maa".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -3811,6 +3858,7 @@ mod tests {
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -3973,6 +4021,7 @@ mod tests {
agent,
Some(&caps(r#"{"cpu_quota":"400%"}"#)),
Some(&inference),
None,
)
.await;
assert!(stored.is_err(), "{agent}: the inference write must fail");
@ -3991,6 +4040,60 @@ mod tests {
);
}
/// Through the real handler: when the inference store fails after the
/// permissions are stored, the creation is refused and the permissions go
/// back to what they were, the earlier value or no key. The inference
/// bucket refuses any value, as above.
#[tokio::test]
async fn a_failed_creation_leaves_no_permissions_behind() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
async_nats::jetstream::new(client.clone())
.create_key_value(async_nats::jetstream::kv::Config {
bucket: swarm_queue_client::agent_inference::BUCKET.to_owned(),
history: 1,
max_value_size: 1,
..Default::default()
})
.await
.expect("a bucket that refuses any value");
let state = crate::agent_limits::tests::state(&client);
let earlier = crate::agent_permissions::at_create(vec![
hive_sh4re::permissions::Permission::Messaging,
])
.expect("valid");
crate::agent_permissions::store(&client, "atlas", &earlier)
.await
.expect("an earlier grant");
for agent in ["atlas", "iris"] {
let req: super::CreateAgentRequest = serde_json::from_str(&format!(
r#"{{"name":"{agent}","hive":"pr1ma","permissions":["web_tools"],
"inference":{{"model":"opus"}}}}"#
))
.expect("body parses");
super::create_agent(axum::extract::State(state.clone()), axum::Json(req))
.await
.expect_err("the inference write must fail");
}
let bucket = swarm_queue_client::agent_permissions::open_or_create(&client)
.await
.expect("bucket");
let held =
|value: Option<bytes::Bytes>| value.map(|v| String::from_utf8_lossy(&v).into_owned());
assert_eq!(
held(bucket.get("atlas").await.expect("read")).as_deref(),
Some(r#"{"permissions":["messaging"]}"#),
"the earlier grant comes back"
);
assert_eq!(
held(bucket.get("iris").await.expect("read")),
None,
"a grant stored for a name with none is removed"
);
assert_eq!(node_count(&state.jobq), 0, "nothing may be queued");
}
/// A valid cap beside an invalid model stores neither and queues nothing,
/// on a server that could have stored the cap.
#[tokio::test]
@ -4044,6 +4147,7 @@ mod tests {
hive: hive.to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -5166,6 +5270,7 @@ mod tests {
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -5219,6 +5324,7 @@ mod tests {
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -5268,6 +5374,7 @@ mod tests {
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -5325,6 +5432,7 @@ mod tests {
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
@ -5529,6 +5637,7 @@ mod tests {
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await

View file

@ -564,7 +564,7 @@ impl Policy {
"$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_limits::STREAM
));
// The capabilities the swarm grants those agents
// The permissions the swarm grants those agents
// (`swarm_queue_client::agent_permissions`), read the same way and
// every key for the same reason.
subjects.push(format!(
@ -852,15 +852,15 @@ mod tests {
}
/// A hive reads any agent's permissions and can change none of them; an
/// agent, even one granted direct gets on its own keys elsewhere, reads
/// none of them.
/// agent, with the template `swarm-nats.nix` passes, reads its own key and
/// no other, and writes none.
#[test]
fn a_hive_may_read_every_agents_permissions_and_an_agent_none() {
fn a_hive_reads_every_agents_permissions_and_an_agent_only_its_own() {
use swarm_queue_client::agent_permissions::{STREAM, direct_get_subject, key_subject};
let p = policy()
.with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.{agent}".to_owned(),
"$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}".to_owned(),
])
.expect("a per-agent template is valid");
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
@ -880,15 +880,10 @@ mod tests {
let agent = p.agent_token_permissions("atlas").expect("configured");
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
assert!(
!agent_may(&direct_get_subject("atlas")),
"an agent may read its own permissions: {:?}",
agent.publish
);
// Control: the agent's direct-get grant is real, just not this bucket.
assert!(agent_may(
&swarm_queue_client::agent_inference::direct_get_subject("atlas")
));
assert!(agent_may(&direct_get_subject("atlas")));
for forbidden in [direct_get_subject("ruth"), key_subject("atlas")] {
assert!(!agent_may(&forbidden), "atlas may publish {forbidden}");
}
}
/// A hive sends its import on its own subject only; the reader hears every

View file

@ -50,9 +50,10 @@ schema = ["dep:utoipa"]
# `agent_limits::read`, for hive-c0re, which reads the key of each agent it
# runs the same way. Explicit for the same reason as `notices`.
agent-limits = ["async-nats/jetstream"]
# `agent_permissions::AgentPermissions` and `read`, for hive-c0re and
# swarm-controller. Pulls `hive-sh4re` for the `Capability` names, so the
# crates that only need the bucket name do not link it.
# `agent_permissions::AgentPermissions` and `read`, for hive-c0re, the agent
# harness, the subagent daemon and swarm-controller. Pulls `hive-sh4re` for the
# `Permission` names, so the crates that only need the bucket name do not link
# it.
agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"]
[dependencies]

View file

@ -1,17 +1,18 @@
//! The per-agent permissions KV bucket: one key per agent, holding the
//! capabilities the swarm grants it.
//! [`hive_sh4re::permissions::Permission`] set the swarm grants it.
//!
//! **swarm-controller is the only writer**, and creates the bucket when it
//! starts, so a bucket that does not answer means the queue is not serving it,
//! never "nothing set". A field left unset leaves the agent on its hive's
//! compiled-in default; an empty list grants nothing.
//! starts. A missing key or an unset `permissions` field leaves the agent on
//! the compiled-in default (`Permission::default_for`); an empty list grants
//! nothing.
//!
//! **The hive running the agent reads the key**, not the agent: capabilities
//! are bind mounts the host sets up at container start. A hive reads with one
//! `DIRECT.GET` per agent
//! ([`crate::agent_permissions::direct_get_subject`]), and its grant covers
//! every key of this bucket, read-only, for the reason [`crate::agent_limits`]
//! gives.
//! **Two readers, each enforcing its own part.** The hive running the agent
//! applies `manage_root_agent` as bind mounts at container start; its grant
//! covers every key of this bucket, read-only, for the reason
//! [`crate::agent_limits`] gives. The agent itself (its harness and subagent
//! daemon) applies the tool permissions; its grant covers only its own key.
//! Both read with one `DIRECT.GET` of
//! [`crate::agent_permissions::direct_get_subject`].
//!
//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move
//! between hives, and its permissions go with it.
@ -76,14 +77,14 @@ pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Vec<hive_sh4re::permissions::Capability>>,
pub permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
}
/// What a hive sends once: the capabilities its meta repo held, by agent.
/// What a hive sends once: the permissions its meta repo held, by agent.
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Import {
pub capabilities: std::collections::BTreeMap<String, Vec<hive_sh4re::permissions::Capability>>,
pub permissions: std::collections::BTreeMap<String, Vec<hive_sh4re::permissions::Permission>>,
}
/// The controller's answer to an [`Import`].
@ -131,7 +132,8 @@ pub enum Read {
NoBucket,
}
/// Read `agent`'s current permissions with one direct get.
/// Read `agent`'s current permissions with one direct get: the one subject
/// an agent's own grant covers, and one of those a hive's covers.
///
/// # Errors
///
@ -220,16 +222,16 @@ mod tests {
#[cfg(feature = "agent-permissions")]
#[test]
fn unset_and_empty_are_different_values() {
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
assert_eq!(
serde_json::to_string(&AgentPermissions::default()).unwrap(),
"{}"
);
let none: AgentPermissions = serde_json::from_str(r#"{"capabilities":[]}"#).unwrap();
assert_eq!(none.capabilities, Some(Vec::new()));
let none: AgentPermissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
assert_eq!(none.permissions, Some(Vec::new()));
let ruth: AgentPermissions =
serde_json::from_str(r#"{"capabilities":["manage_root_agent"]}"#).unwrap();
assert_eq!(ruth.capabilities, Some(vec![Capability::ManageRootAgent]));
serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
}
/// Fields a newer controller adds must not cost an older hive the ones it
@ -239,10 +241,10 @@ mod tests {
fn decoding_tolerates_unknown_fields_and_reads_a_delete_as_unset() {
let decoded = decode(
None,
br#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#,
br#"{"permissions":["manage_root_agent"],"later_field":["meta"]}"#,
)
.unwrap();
assert_eq!(decoded.capabilities.map(|c| c.len()), Some(1));
assert_eq!(decoded.permissions.map(|c| c.len()), Some(1));
assert_eq!(
decode(Some("DEL"), b"").unwrap(),
AgentPermissions::default()

View file

@ -291,12 +291,13 @@ enum AgentVerb {
#[command(subcommand)]
command: InferenceVerb,
},
/// Show or change the capabilities the swarm grants an agent.
/// Show or change the permissions the swarm grants an agent.
///
/// The hive running the agent reads them every 30 seconds and, when they
/// change, restarts the agent after its current turn. Unset leaves the
/// agent on its hive's default: none, except `ruth`, who gets
/// `manage_root_agent`.
/// The agent applies its tool permissions at its next turn; the hive
/// running it restarts it, after its current turn, when
/// `manage_root_agent` changes. Unset leaves the agent on the default:
/// `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent`
/// for `ruth`.
Permissions {
#[command(subcommand)]
command: PermissionsVerb,
@ -305,11 +306,11 @@ enum AgentVerb {
#[derive(Subcommand)]
enum PermissionsVerb {
/// Show the capabilities the swarm grants an agent, or that it sets none.
/// Show the permissions the swarm grants an agent, or that it sets none.
Get(InferenceAgentArgs),
/// Replace the capabilities the swarm grants an agent.
/// Replace the permissions the swarm grants an agent.
Set(PermissionsSetArgs),
/// Clear the agent's permissions, so its hive's default applies again.
/// Clear the agent's permissions, so the default applies again.
Clear(InferenceAgentArgs),
}
@ -317,15 +318,16 @@ enum PermissionsVerb {
struct PermissionsSetArgs {
/// Agent name. A hive must declare it.
agent: String,
/// The capabilities to grant, comma-separated (`manage_root_agent`).
/// `--capabilities ''` grants none.
/// The permissions to grant, comma-separated: any of `messaging`,
/// `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`.
/// `--permissions ''` grants none.
#[arg(
long,
value_name = "CAPABILITIES",
value_name = "PERMISSIONS",
value_delimiter = ',',
required = true
)]
capabilities: Vec<String>,
permissions: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
@ -716,7 +718,7 @@ fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
match command {
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
PermissionsVerb::Set(a) => {
permissions::set(&a.socket.resolve(remote)?, &a.agent, a.capabilities)
permissions::set(&a.socket.resolve(remote)?, &a.agent, a.permissions)
}
PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
}

View file

@ -1,12 +1,12 @@
//! `swarmctl agent permissions` — the capabilities the swarm grants one
//! `swarmctl agent permissions` — the permissions the swarm grants one
//! agent, through swarm-controller's `/api/agents/{name}/permissions` routes.
//!
//! The hive running the agent reads its key every 30 seconds and restarts the
//! agent, after its current turn, when its capabilities change. Unset leaves
//! the agent on its hive's default; an empty list grants nothing.
//! The agent applies its tool permissions at its next turn; the hive running
//! it restarts it, after its current turn, when `manage_root_agent` changes.
//! Unset leaves the agent on the default set; an empty list grants nothing.
//!
//! The shape below mirrors the controller's `Permissions`, for the reason
//! [`crate::agent`]'s module doc gives. Capability names are checked by the
//! [`crate::agent`]'s module doc gives. Permission names are checked by the
//! controller, which answers 422 for one it does not know.
use anyhow::{Context as _, Result};
@ -19,7 +19,7 @@ use crate::remote::Target;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Permissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
capabilities: Option<Vec<String>>,
permissions: Option<Vec<String>>,
}
fn runtime() -> Result<tokio::runtime::Runtime> {
@ -50,26 +50,26 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
Ok(())
}
/// Run `swarmctl agent permissions set`: store the given capabilities, which
/// Run `swarmctl agent permissions set`: store the given permissions, which
/// replace the agent's record whole.
pub(crate) fn set(target: &Target, agent: &str, capabilities: Vec<String>) -> Result<()> {
pub(crate) fn set(target: &Target, agent: &str, permissions: Vec<String>) -> Result<()> {
let uri = member(agent)?;
let stored: Permissions = runtime()?.block_on(send(
target,
hyper::Method::PUT,
&uri,
Some(&with_capabilities(capabilities)),
Some(&with_permissions(permissions)),
"permissions-set",
))?;
print!("{}", render(&stored));
Ok(())
}
/// The record granting `capabilities`. An empty name, as `--capabilities ''`
/// The record granting `permissions`. An empty name, as `--permissions ''`
/// gives, is dropped, so that spelling grants nothing.
fn with_capabilities(capabilities: Vec<String>) -> Permissions {
fn with_permissions(permissions: Vec<String>) -> Permissions {
Permissions {
capabilities: Some(capabilities.into_iter().filter(|c| !c.is_empty()).collect()),
permissions: Some(permissions.into_iter().filter(|c| !c.is_empty()).collect()),
}
}
@ -83,17 +83,17 @@ pub(crate) fn clear(target: &Target, agent: &str) -> Result<()> {
None,
"permissions-clear",
))?;
println!("cleared; {agent} goes back to its hive's default");
println!("cleared; {agent} goes back to the default");
Ok(())
}
fn render(p: &Permissions) -> String {
let caps = match p.capabilities.as_deref() {
None => "(not set; the hive's default)".to_owned(),
let caps = match p.permissions.as_deref() {
None => "(not set; the default)".to_owned(),
Some([]) => "(none)".to_owned(),
Some(caps) => caps.join(", "),
};
format!("capabilities: {caps}\n")
format!("permissions: {caps}\n")
}
#[cfg(test)]
@ -103,24 +103,21 @@ mod tests {
#[test]
fn get_tells_unset_from_none() {
let unset: Permissions = serde_json::from_str("{}").unwrap();
assert_eq!(
render(&unset),
"capabilities: (not set; the hive's default)\n"
);
let none: Permissions = serde_json::from_str(r#"{"capabilities":[]}"#).unwrap();
assert_eq!(render(&none), "capabilities: (none)\n");
assert_eq!(render(&unset), "permissions: (not set; the default)\n");
let none: Permissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
assert_eq!(render(&none), "permissions: (none)\n");
let ruth: Permissions =
serde_json::from_str(r#"{"capabilities":["manage_root_agent"]}"#).unwrap();
assert_eq!(render(&ruth), "capabilities: manage_root_agent\n");
serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
assert_eq!(render(&ruth), "permissions: manage_root_agent\n");
}
/// `--capabilities ''` stores an empty list, not one empty name.
/// `--permissions ''` stores an empty list, not one empty name.
#[test]
fn an_empty_list_flag_grants_nothing() {
let set = with_capabilities(vec![String::new()]);
let set = with_permissions(vec![String::new()]);
assert_eq!(
serde_json::to_string(&set).unwrap(),
r#"{"capabilities":[]}"#
r#"{"permissions":[]}"#
);
}
}