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",
"serde_json", "serde_json",
"strum", "strum",
"tracing",
] ]
[[package]] [[package]]

View file

@ -2,8 +2,8 @@
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
Agents are a **flat set**: the capability store scopes which agents can Agents are a **flat set**: the `manage_root_agent` permission scopes
manage which others. which agents can manage which others.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
@ -79,7 +79,7 @@ module docs for the exact call chain.
## Hard-coded manager behaviour ## Hard-coded manager behaviour
The **manager (`ruth`)** differs from other agents in naming/bootstrap, 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 allow-list and state dirs work as for every other agent. A hive with
`services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips `services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips
its root-agent create/start sweep its root-agent create/start sweep
@ -107,7 +107,7 @@ apply.
manage any agent's state dir — config isn't authored there, since a 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 real config change is a PR from a clone), plus RO mounts for
`/applied` (diff against what's deployed) and `/meta` (system-wide `/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. 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 The operator dashboard's `POST /api/meta-update` is the only path
that writes `flake.lock`. that writes `flake.lock`.
@ -118,11 +118,10 @@ apply.
`hive-agent/src/prompt.rs`'s own module doc). The `role:manager` `hive-agent/src/prompt.rs`'s own module doc). The `role:manager`
blocks are dead in production, exercised only by a unit test blocks are dead in production, exercised only by a unit test
(`filter_role_blocks(SAMPLE, "manager")`). (`filter_role_blocks(SAMPLE, "manager")`).
- **Tool allow-list** — the MCP tools claude sees come from - **Tool allow-list** — the MCP tools claude sees come from the agent's
`HIVE_TOOL_GROUPS` alone, the same mechanism for every agent permissions alone, the same mechanism for every agent
([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's wider default ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's default
surface is a wider default grant (`ToolGroup::MANAGER_DEFAULT`, seeded permissions add only `manage_root_agent` (`Permission::default_for`).
by `auto_update.rs` whenever its groups aren't already set).
- **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is - **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is
injected uniformly via `systemd.globalEnvironment` for every injected uniformly via `systemd.globalEnvironment` for every
container including the manager, so all token/state paths resolve 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 calls `bind_child_agent_dirs` for each entry in
`topology::all_agents()`). it's a copy to *read* another agent's `topology::all_agents()`). it's a copy to *read* another agent's
current config — not an editing surface. An agent without the 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 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 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 the operator review and merge it. By design, no second, mount-shaped
path reaches the same file without the review. 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 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`: mounts via `set_nspawn_flags`:
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's - `/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. manage any agent", so the mounted set is every agent.
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's - `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
authoritative applied repo, including `.git`. authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide - `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide
deploy flake. 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. dirs.
⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts ⚠️ 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 The root agent holds the permission by default: an agent whose record
leaves `capabilities` unset gets the hive's default, which is leaves `permissions` unset gets the default, which includes
`manage_root_agent` for `ruth` and nothing for every other agent. `manage_root_agent` for `ruth` and for no other agent. Setting her
Setting `"capabilities": []` for her revokes it. permissions without it revokes it.
Each proposed repo (`/agents/<n>/config/`) is pre-configured Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at 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 The system prompt (`hive-agent/prompts/system.md`, rendered by
`hive-agent/src/prompt.rs`) is the **same for every agent**; what `hive-agent/src/prompt.rs`) is the **same for every agent**; what
varies is which MCP tools it surfaces (gated by tool groups and varies is which MCP tools it surfaces (gated by the agent's permissions,
capabilities in `agent.nix`). No `role:manager` block renders only which the swarm sets). No `role:manager` block renders only
for the root agent. The root agent's role-specific for the root agent. The root agent's role-specific
behaviour comes from its CLAUDE.md / agent-specific instructions, not behaviour comes from its CLAUDE.md / agent-specific instructions, not
the system prompt template. the system prompt template.

View file

@ -329,14 +329,14 @@ Under `/var/lib/hyperhive/agents/<name>/`:
### Cross-agent access to state ### 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 agent's `state` dir bind-mounted **read-write** and its `config` dir
**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`). **read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`).
The RW on `state` is deliberate, not an oversight: the holder recovers The RW on `state` is deliberate, not an oversight: the holder recovers
other agents, which includes writing into their state (for example other agents, which includes writing into their state (for example
seeding notes, clearing a stuck sentinel) as well as reading it. 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. sees its own dirs and nothing else.
**`harness` isn't mounted at all.** It holds that agent's own runtime **`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 --> <!-- vale write-good.Passive = YES -->
Isolation still holds by default: a container has its *own* dirs and, 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 Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never applied repo. Tracks `flake.nix` (module-only boilerplate; never
@ -384,13 +384,9 @@ Contents:
audit trail (one commit per successful deploy or hyperhive bump). audit trail (one commit per successful deploy or hyperhive bump).
- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`). - `topology.json` — the agent roster (`["alice", "bob", "ruth"]`).
Written by `topology::reconcile` on every meta sync; read by Written by `topology::reconcile` on every meta sync; read by
`topology::all_agents`, which is the set the `ManageRootAgent` `topology::all_agents`, which is the set the `manage_root_agent`
capability grants mounts over. The reader also accepts a map-shaped permission grants mounts over. The reader also accepts a map-shaped
file carrying a `parent` per agent, and keeps its keys. 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 - `swarm-resource-limits.json` — the caps the swarm sets for this
hive's agents hive's agents
(`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`). (`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`).
@ -415,11 +411,11 @@ Contents:
The root agent has the meta dir RO-mounted at `/meta/`. The root agent has the meta dir RO-mounted at `/meta/`.
Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json` 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 `workers::swarm_permissions` last read them from the swarm's
`agent-permissions` bucket (every 30 seconds): a copy, not the `agent-permissions` bucket (every 30 seconds): a copy, not the
record, which `swarmctl agent permissions` sets. `set_nspawn_flags` 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. 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 `GetLooseEnds` takes no target — it always returns the caller's own
rows, on both the agent and manager sockets alike (the manager, `ruth`, rows, on both the agent and manager sockets alike (the manager, `ruth`,
is a normal agent here, just deployed automatically with different 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: 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 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. 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`) One set of named `Permission` values (`hive_sh4re::permissions::Permission`)
determines the MCP tool surface an agent receives, not a hardcoded decides what an agent may do: which MCP and built-in tools it gets, and which
binary flavor. host access it has.
| Group | Tools | | Permission | Grants |
|---|---| |---|---|
| `messaging` | `send`, `recv`, `ack_until` | | `messaging` | `send`, `recv`, `ack_until` |
| `meta` | `get_agent_meta` (`set_status` is always-on, see below) | | `meta` | `get_agent_meta` (`set_status` is always-on, see below) |
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` | | `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`. | | `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`. |
| `lifecycle` | none | | `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
| `forge` | none | | `manage_root_agent` | every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus `/applied` and `/meta` (ro) |
| `web_tools` | none (gates the Claude built-ins `WebFetch`/`WebSearch`, not an MCP tool) |
**Always-on tools** — `ToolGroup::ALWAYS_ON_TOOLS` exposes `set_status`, **Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of which `compact`, and `mark_todos_done` to every agent regardless of its
groups it holds. The operator dashboard depends on every agent permissions. The operator dashboard depends on every agent being able to
being able to report its status chip, and the server-side `SetStatus` handler report its status chip, and the server-side `SetStatus` handler has no
has no tool-group check (only length validation), so gating it would only permission check (only length validation), so gating it would only desync
desync the `--allowedTools` list from what the host actually accepts. the `--allowedTools` list from what the host actually accepts. Revoking
Revoking `meta` therefore drops `get_agent_meta` but never `set_status`. `meta` therefore drops `get_agent_meta` but never `set_status`.
`mark_todos_done` is here because producers push todos to an agent independent of `mark_todos_done` is here because producers push todos to an agent whether
whether it holds `inbox` — an agent without that group still needs a way to or not it holds `inbox`, and an agent without it still needs to clear them.
clear them.
**Config storage** — per-agent tool groups live in **Where they live** — in the swarm, not on the hive: the `permissions` field of
`/var/lib/hyperhive/meta/tool-groups.json` (hive-c0re-owned, committed to the the agent's key in the `agent-permissions` KV bucket
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
(`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone (`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone
writes. Set them with `swarmctl agent permissions get|set|clear <agent>` writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
(`/api/agents/{name}/permissions`). Unset means the hive's default, which is a (`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
constant in hive-c0re (`capabilities::default_for`): none, except `ruth`, who (`permissions` in `POST /api/agents`). A missing key, an unset field, or no
gets `manage_root_agent`. `"capabilities": []` grants none. 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 **How a change applies** —
agent it runs (`workers/swarm_permissions.rs`) and stores the result in - The agent's harness reads its own key every 10 seconds and builds every
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo. nspawn turn's `--tools`, `--allowedTools` and MCP config from the last value, so a
fixes the bind mounts at container start, so when an agent's capabilities change reaches the next turn. An ACP agent's permission check reads it on
change hive-c0re restarts it after its current turn. A failed read keeps the each request.
last stored value; it never falls back to the default. - 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 Every reader keeps its last good value on a failed read.
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`).
**Capability NOT configurable from `agent.nix`** — same reasoning as tool **Moving off the meta repo** — on its first boot with this code a hive copies
groups: an agent that could grant its own capabilities via a config commit would `meta/capabilities.json` and `meta/tool-groups.json` into the stored value,
bypass the operator approval gate. 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 **Not configurable from `agent.nix`** — that file changes through config PRs
`hive-sh4re/src/permissions.rs` and to `Capability::ALL`. Implement the access an agent can open, so letting it declare its own permissions would let an
check where hive-c0re enforces it (today: `set_nspawn_flags` in agent grant itself any tool by submitting a config commit, bypassing the
`hive-c0re/src/lifecycle/host_config.rs`). 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 ## 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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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 - **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue
resource declared by every node kind that mutates the meta repo — `MetaSync`, 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 mutations can therefore never interleave, so no commit lands inside another
node's window. It's a queue resource rather than a runtime mutex 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 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) 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») 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) 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 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; 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) 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 (`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 so a power-op DAG's intent write + reconcile is atomic — two racing ops can't
clobber intent before either reconciles.) **Lease-exempt**: `MetaSync`, 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 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 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`, reason, are the rebuild subtree's own members (`StopForUpdate`, `Swap`,

View file

@ -7,18 +7,18 @@ domain, covers host-level detail for one hive.
## What it shows ## What it shows
| route | what | | route | what |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop; the swarm name in the header links here | | `/` | 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 | | `/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>/terminal` | one agent's live terminal |
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' 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 | | `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), read through the controller with a Viewer service-account token |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/issues` | a cross-repo issue report | | `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it An agent created here or with `swarmctl agent create` starts `paused`; set it

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 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 unconditional grant. Even where the entry is present, the server is only
actually rendered into an agent's claude config when the agent also 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 `hive-agent/src/mcp_config.rs` suppresses it entirely without that
group, so an agent lacking `execution` gets no `mcp__bash__*` tools at permission, so an agent lacking `execution` gets no `mcp__bash__*` tools at
all, not just an unused grant. See "Relationship to the `execution` tool all, not just an unused grant. The harness rewrites the config each turn,
group" below. so a change applies at the next one. See "Relationship to the
`execution` permission" below.
## Tools ## 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 delivered the result instead clears the keyed todo, so no redundant
loose-end follows. 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: `Permission::Execution` (in `Permission::DEFAULT`) is the real gate on bash:
`extra_server_required_group` (in `hive-agent/src/mcp_config.rs`) `extra_server_required_permission` (in `hive-agent/src/mcp_config.rs`)
controls whether the `bash` MCP server reaches the agent's claude controls whether the `bash` MCP server reaches the agent's claude
config at all, suppressing every `mcp__bash__*` tool config at all, suppressing every `mcp__bash__*` tool
when absent — not just leaving them unused. Its own `tools()` returns when absent — not just leaving them unused. Its own `tools()` returns
`&[]`: `mcp__bash__run`/`status`/`kill` live on the separate `bash` MCP `&[]`: `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` `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 **A subagent gets exactly the built-ins its parent agent has, plus one
deliberate exception** — the base resolution deliberate exception** — the base resolution
(`hive_sh4re::permissions::subagent_builtin_tools_arg`, wrapping the (`hive_sh4re::permissions::subagent_builtin_tools_for`, wrapping the
harness's own `builtin_tools_for`) from the same `HIVE_TOOL_GROUPS`: 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`/ `Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`, plus `WebFetch`/
`WebSearch` for an agent granted the `web_tools` tool group and not `WebSearch` for an agent granted `web_tools` and not otherwise. A running
otherwise. See subagent keeps the tools it started with. See
[the harness's own allowlist](../turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools) [the harness's own allowlist](../turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools)
for what that list contains and why. for what that list contains and why.
The exception: **a subagent gets Claude's built-in `Bash` iff its parent 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 parent shell execution, via the out-of-process `bash` MCP server
(`mcp__bash__run`/`status`/`kill`). A subagent has no MCP server of its (`mcp__bash__run`/`status`/`kill`). A subagent has no MCP server of its
own by default (see [MCP servers](#mcp-servers---strict-mcp-config) own by default (see [MCP servers](#mcp-servers---strict-mcp-config)
below), so there is no `mcp__bash__*` for it to inherit that capability 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 through; it gets Claude's own tool instead. The rule is the capability
transfers, not the mechanism — the parent itself never gains built-in 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. `execution`-holding parent does.
Inheriting rather than listing is the point: a hardcoded subagent list 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 * `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 * `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 * `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` ## `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>` **Usage:** `swarmctl agent permissions <COMMAND>`
###### **Subcommands:** ###### **Subcommands:**
* `get` — Show the capabilities the swarm grants an agent, or that it sets none * `get` — Show the permissions the swarm grants an agent, or that it sets none
* `set` — Replace the capabilities the swarm grants an agent * `set` — Replace the permissions the swarm grants an agent
* `clear` — Clear the agent's permissions, so its hive's default applies again * `clear` — Clear the agent's permissions, so the default applies again
## `swarmctl agent permissions get` ## `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>` **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` ## `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:** ###### **Arguments:**
@ -249,7 +249,7 @@ Replace the capabilities the swarm grants an agent
###### **Options:** ###### **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. * `--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`. 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` ## `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>` **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` One harness serve binary (`hive-agent`, with its `hive-agent-mcp`
sibling), one shared `nix/agent-modules/` tree, one service unit sibling), one shared `nix/agent-modules/` tree, one service unit
(`systemd.services.hive-agent`) for all agents. Privilege (`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). and manager-surface calls each agent receives).
`agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`. `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 custom static files, connectivity overrides, claude plugins, cargo message
filtering). filtering).
- **[mcp.md](mcp.md)** — the MCP tool surface claude sees: core tools, - **[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. and the built-in tool allowlist.
Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages 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) [`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. for what reaches a subagent's own `--mcp-config` and what doesn't.
Tool groups (`HIVE_TOOL_GROUPS`) gate tool access. The default The agent's permissions gate tool access
preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and ([`docs/process/conventions.md#permissions`](../process/conventions.md)).
`execution`. The privileged `lifecycle` group The default (`Permission::DEFAULT`) is `messaging`, `meta`, `inbox` and
is opt-in via the P3RM1SS10NS tab. `execution`; `swarmctl agent permissions set` changes it, and the next turn
runs on the change.
## Core tools (always available) ## 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 `homeserver`); omitted for agents with no matrix provisioning. Omit
`name` to query self. `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)`. `mark_todos_done(ids)`.
- `set_status` — set a free-text status string visible on the - `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 unknown or already-done ids are silently skipped. Dials the same
in-agent todo socket as `cancel_loose_end`'s `todo` kind. 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 - **Bash execution** (`execution`) — background shell tasks. See
[`docs/tools/bash.md`](../tools/bash.md). [`docs/tools/bash.md`](../tools/bash.md).
- **Subagent spawning** — nested sessions on the agent's own runtime - **Subagent spawning** — nested sessions on the agent's own runtime
(claude or ACP, via `hive-runtime`) as background tasks, shipped (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). [`docs/tools/subagent.md`](../tools/subagent.md).
- **Lifecycle + config** (`lifecycle`) — gates nothing. - **Config changes** — no permission: they go through a forge PR on
Config changes go through a forge PR on `agent-configs/<name>` — see `agent-configs/<name>` — see
[`docs/agent-lifecycle/config-changes.md`](../agent-lifecycle/config-changes.md). [`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` - **Web egress** (`web_tools`) — enables Claude's built-in `WebFetch`
and `WebSearch` tools (not MCP tools; added directly to the and `WebSearch` tools (not MCP tools; added directly to the
`--allowedTools` list). Off by default; add the group in the `--allowedTools` list). Off by default.
P3RM1SS10NS tab and rebuild to enable. - **Recovery mounts** (`manage_root_agent`) — no tool: every agent's state
- **Capabilities** — orthogonal to tool groups, set via the P3RM1SS10NS and config mounted into this agent's container. See
tab. No capability registers an MCP tool today; the full list and their [`docs/process/conventions.md#permissions`](../process/conventions.md).
effects are in
[`docs/process/conventions.md#capabilities`](../process/conventions.md).
- **Matrix MCP + extra servers** — `mcp__matrix__*` tools and - **Matrix MCP + extra servers** — `mcp__matrix__*` tools and
per-agent extra MCP config. See per-agent extra MCP config. See
[`docs/tools/matrix.md`](../tools/matrix.md). [`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`) ## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`)
The built-in list and the `--tools` value it resolves to live in 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 — subagent daemon spawns its own `claude` and must resolve the same set —
see [`docs/tools/subagent.md`](../tools/subagent.md). 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 — `Skill` is what makes an installed plugin's `SKILL.md` invokable —
without it, a skill's `description` frontmatter never gets seen by without it, a skill's `description` frontmatter never gets seen by
the model no matter how well it matches the task. the model no matter how well it matches the task.
- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added once the - Permission-gated built-ins: `WebFetch`, `WebSearch` (added once the
operator enables the `web_tools` tool group — see P3RM1SS10NS tab). agent holds `web_tools`).
- Denied by omission (absent from the harness `--tools` / - Denied by omission (absent from the harness `--tools` /
`--allowedTools`, so they "literally don't exist" in a harness turn): `--allowedTools`, so they "literally don't exist" in a harness turn):
`Bash`, `Task`, `NotebookEdit`, `TodoWrite`. `Bash`, `Task`, `NotebookEdit`, `TodoWrite`.
- Additionally in the managed-settings deny list - Additionally in the managed-settings deny list
(`/etc/claude-code/managed-settings.json`, un-overridable): `Task`, (`/etc/claude-code/managed-settings.json`, un-overridable): `Task`,
`TodoWrite`. `Bash` **isn't** in the managed deny — see below. `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 The autonomous harness disallows `Bash` — shell execution goes
through `mcp__bash__run` (background tasks with structured output + 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`), 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 so built-in shell there stays within the existing trust boundary. The bash MCP server
(`run` / `status` / `kill`) uses `allowedTools = ["*"]` so all (`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` `WebFetch` / `WebSearch` are off by default; grant `web_tools`
tool group in the P3RM1SS10NS tab and rebuild the agent to enable them. (`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. never more than one step from the hub.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most 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 - **4G3NTS** — every agent, live. This is the default tab and the one
you'll check most. you'll check most.
- **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build - **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build
lifecycle: the rebuild queue with a live log of whatever is building, lifecycle: the rebuild queue with a live log of whatever is building,
the meta flake inputs you can bump, and the build log history. 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 Everything else lives on its own page instead, all reachable from the
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across 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 streaming log of whatever's currently building. Useful right after
merging a config change or bumping a flake input. merging a config change or bumping a flake input.
**Grant or revoke a tool/capability.** P3RM1SS10NS is a checkbox matrix **Grant or revoke a permission.** Not on the hive dashboard: an agent's
— rows are agents, columns are tool groups or capabilities. Nothing permissions live in the swarm. Set them with `swarmctl agent permissions`,
takes effect until you hit **save all** at the bottom of the tab; a or on the swarm UI's create-agent screen when creating the agent; see
save queues a rebuild for whichever agents actually changed. [`process/conventions.md`](../process/conventions.md#permissions).
**Read an agent's logs.** Logs aren't in the hive dashboard. Every **Read an agent's logs.** Logs aren't in the hive dashboard. Every
hive ships its agents', daemons' and infra containers' journals to the 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 - **← 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. at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`, - **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 the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the 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, `/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 **K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that
cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed` and `tool_groups_changed` `tombstones_changed`. (Rebuild queue and meta inputs are dashboard
(the latter re-renders the stale-perms sub-section when permission tabs — see below.)
data changes). (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 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 same figures go out as OTEL metrics and appear in Grafana (see
[observability](../scheduler/observability.md)). An agent's CPU and memory [observability](../scheduler/observability.md)). An agent's CPU and memory
caps live per agent in the swarm UI. caps live per agent in the swarm UI.
**K3PT ST4T3** — two sub-sections on one pane: **K3PT ST4T3** — agents with kept state and **no container** (size + age +
*Tombstones*: agents with kept state and **no container** (size + age +
claude-creds badge). One action: `PURG3` (wipes state + applied claude-creds badge). One action: `PURG3` (wipes state + applied
dirs; `POST /api/purge-tombstone/{name}`). 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 / needs a recorded destroy — **deferred** to the swarm-controller /
snapshot-storage rework where the problem changes shape. 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`) ## Build tabs (`#rebuild`, `#meta`, `#buildlogs`)
The build lifecycle — rebuild queue, live build log, meta inputs, and 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 (suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
entry with that id pre-expanded. 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`) ## ST4TS page (`/stats.html`)
Hive-wide turn statistics, aggregated across every agent's 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/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery - `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries). 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 - `POST /api/meta-update` — `nix flake update` the selected
`meta/flake.lock` inputs, then rebuild the affected agents. `meta/flake.lock` inputs, then rebuild the affected agents.
- `GET /api/dashboard/stream` — unified live event channel: - `GET /api/dashboard/stream` — unified live event channel:
@ -805,11 +701,6 @@ payload):
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`). 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>`) — - `tombstones_changed` (seq, tombstones: `Vec<TombstoneView>`) —
full snapshot of destroyed-but-state-kept agents. Emitted on full snapshot of destroyed-but-state-kept agents. Emitted on
destroy, purge, and revive. C0R3 K3PT ST4T3 tab subscribes. destroy, purge, and revive. C0R3 K3PT ST4T3 tab subscribes.

View file

@ -13,10 +13,9 @@
<body class="core-shell"> <body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at <!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the /core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones) and the dashboard tab strip stays lean: kept state (tombstones). Rebuild
stale permission entries. Rebuild queue + meta inputs are tabs of queue + meta inputs are tabs of /dashboard.html. The section <div>
/dashboard.html. The section <div> ids (tombstones-section, id (tombstones-section) matches what core.js's renderers target. Hive
tombstones-stale-perms) match what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only no dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. --> control surface. -->
@ -26,11 +25,7 @@
</header> </header>
<main class="core-main"> <main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button). <!-- 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. -->
<p class="meta"> <p class="meta">
kept state from previously tombstoned agents — recreating an agent with kept state from previously tombstoned agents — recreating an agent with
the same name reuses it. the same name reuses it.
@ -38,7 +33,6 @@
<div id="tombstones-section"> <div id="tombstones-section">
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>
<div id="tombstones-stale-perms"></div>
</main> </main>
<script type="module" src="/static/core.js" defer></script> <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 // C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state // 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 // 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 // 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 { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.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) ────────── // ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = []; let tombstonesState = [];
@ -95,92 +95,6 @@ function renderTombstones(s) {
root.append(ul); 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) ────────────────────────── // ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() { function renderAll() {
renderTombstones({ tombstones: tombstonesState }); renderTombstones({ tombstones: tombstonesState });
@ -192,11 +106,6 @@ const SSE_HANDLERS = {
tombstonesState = (ev.tombstones || []).slice(); tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState }); 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 ───────────────────────────────────────────────────────────────── // ─── boot ─────────────────────────────────────────────────────────────────
@ -223,16 +132,13 @@ async function init() {
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
await refreshState(); await refreshState();
fetchAndRenderStalePerms();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all // kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// 13 wire kinds down to the 3 this page acts on. This page was one // to the kinds this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this // of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream- // (subscription discipline, part 1 of the dashboard-event-stream-
// split issue). // split issue).
const es = openStream( const es = openStream("/api/dashboard/stream?kinds=tombstones_changed");
"/api/dashboard/stream?kinds=tombstones_changed,tool_groups_changed",
);
if (es) { if (es) {
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;

View file

@ -566,107 +566,6 @@ footer .banner-thin {
margin-bottom: 0.8em; 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 — sticky-bottom strip for bulk actions. */
.selection-bar { .selection-bar {
position: fixed; position: fixed;

View file

@ -71,18 +71,6 @@
> >
<span class="tab-label">◆ BUILD L0GS ◆</span> <span class="tab-label">◆ BUILD L0GS ◆</span>
</a> </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 <!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only 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> <div id="build-list"><p class="meta">loading…</p></div>
</section> </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 <!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the (`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on 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-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span> <span class="home-tile-label">Dashboard</span>
</span> </span>
<span class="home-tile-desc" <span class="home-tile-desc">containers · builds</span>
>containers · builds · permissions</span
>
</a> </a>
<a class="home-tile" href="/flow.html"> <a class="home-tile" href="/flow.html">
@ -69,7 +67,7 @@
<span class="home-tile-icon" aria-hidden="true">⬡</span> <span class="home-tile-icon" aria-hidden="true">⬡</span>
<span class="home-tile-label">Core</span> <span class="home-tile-label">Core</span>
</span> </span>
<span class="home-tile-desc">kept state · stale permissions</span> <span class="home-tile-desc">kept state</span>
</a> </a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by <!-- 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 // Shared dashboard state — the one piece of cross-domain state in the
// dashboard SPA: the live agent roster. // dashboard SPA: the live agent roster.
// //
// The roster is read by nearly every tab (the container tree, the // The roster is read by nearly every tab (the container tree and the
// tool-group matrix, and the selection bar), so // selection bar), so
// it lives here as a single source of truth that each domain module // it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything // imports rather than threading through call signatures. Everything
// else in the dashboard is domain-local and lives with its own module. // 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 /* 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 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). */ 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 { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js"; import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js"; import { containersState, syncContainersFromSnapshot } from "./state.js";
import {
applyToolGroupsChanged,
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import { import {
initJobqRollup, initJobqRollup,
syncTransientsFromSnapshot, syncTransientsFromSnapshot,
@ -59,11 +54,7 @@ window.marked = marked;
// Sections whose innerHTML gets blown away on each refresh. If the // Sections whose innerHTML gets blown away on each refresh. If the
// operator is typing in one of them, skip the refresh — the next // operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur. // tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [ const MANAGED_SECTION_IDS = ["containers-section", "inbox-section"];
"containers-section",
"inbox-section",
"tool-groups-section",
];
// <details> sections that should survive a refresh need a stable // <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed // `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails // sections and records which keys are currently open; restoreOpenDetails
@ -207,7 +198,6 @@ window.marked = marked;
}, },
meta_inputs_changed: onMetaInputsChanged, meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning, meta_update_running: onMetaUpdateRunning,
tool_groups_changed: applyToolGroupsChanged,
}; };
(function bindDashboardStream() { (function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the // Route through the SharedWorker so all open hyperhive tabs on the
@ -218,13 +208,12 @@ window.marked = marked;
// fallback when SharedWorker isn't supported. // fallback when SharedWorker isn't supported.
// //
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries // 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( const es = openStream(
"/api/dashboard/stream?kinds=" + "/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," + "transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," + "container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running," + "meta_inputs_changed,meta_update_running",
"tool_groups_changed",
); );
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;
@ -256,8 +245,8 @@ window.marked = marked;
})(); })();
// ─── tab routing ─────────────────────────────────────────────────────── // ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / // Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs`
// `#permissions` activate the matching pane on the dashboard. Empty // activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs — // hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from // they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only // the H0M3 hub. Tab routing only
@ -274,14 +263,6 @@ window.marked = marked;
// visibility (bar only belongs on 4G3NTS where agent cards live). // visibility (bar only belongs on 4G3NTS where agent cards live).
document.body.dataset.activeTab = target; document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values())); 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); onBuildsTabShown(target);
} }

View file

@ -25,6 +25,9 @@
// Its name field, hive select, and submit button all come from the // Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than // shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why. // 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 { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact"; import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -44,6 +47,12 @@ import {
InferenceFields, InferenceFields,
type InferenceDraft, type InferenceDraft,
} from "../agent/detail/AgentInference.js"; } 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"; import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -59,6 +68,12 @@ interface CreateAgentResponse {
node_id: number; node_id: number;
} }
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState = type SubmitState =
| { status: "idle" } | { status: "idle" }
| { status: "submitting" } | { status: "submitting" }
@ -103,6 +118,12 @@ export function CreateAgentForm({
EMPTY_INFERENCE_DRAFT, EMPTY_INFERENCE_DRAFT,
); );
const inferenceProblem = draftProblem(inference); 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 // Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one // 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) })); })().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) => ({ const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name, value: h.name,
label: h.name, label: h.name,
@ -146,6 +180,9 @@ export function CreateAgentForm({
hive, hive,
...(Object.keys(limits).length > 0 ? { limits } : {}), ...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}), ...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
}), }),
}); });
if (!r.ok) { if (!r.ok) {
@ -155,6 +192,7 @@ export function CreateAgentForm({
const data = (await r.json()) as CreateAgentResponse; const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id }); setResult({ status: "done", nodeId: data.node_id });
setName(""); setName("");
if (catalogue) setSelected(catalogue.default);
} catch (err) { } catch (err) {
setResult({ status: "error", problem: { detail: String(err) } }); setResult({ status: "error", problem: { detail: String(err) } });
} }
@ -176,6 +214,12 @@ export function CreateAgentForm({
problem={hivesError} problem={hivesError}
/> />
)} )}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
<form class="create-agent-form" onSubmit={submit}> <form class="create-agent-form" onSubmit={submit}>
<TextField <TextField
id="agent-name" id="agent-name"
@ -202,6 +246,19 @@ export function CreateAgentForm({
: "select a hive" : "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"> <details class="create-agent-section">
<summary>resource caps</summary> <summary>resource caps</summary>
<div class="create-agent-section-body"> <div class="create-agent-section-body">
@ -230,6 +287,7 @@ export function CreateAgentForm({
disabled={ disabled={
result.status === "submitting" || result.status === "submitting" ||
!hivesReady || !hivesReady ||
!catalogue ||
inferenceProblem !== null 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. //! One `AgentServer { socket }` struct for all roles.
//! Tool access is gated upstream by `--allowedTools` (derived from the //! 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. //! All tools go through the same `run_tool_envelope` helper.
use std::future::Future; use std::future::Future;
@ -135,7 +135,7 @@ impl AgentServer {
} }
// IMPORTANT: when adding a new `#[tool]` fn to this impl, also add // 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 // Claude Code's permission gate refuses uninlisted MCP tools in
// non-interactive `--print` mode with "permissions not granted yet". // non-interactive `--print` mode with "permissions not granted yet".
#[tool_router] #[tool_router]

View file

@ -44,6 +44,7 @@ serde_json.workspace = true
swarm-queue-client = { workspace = true, features = [ swarm-queue-client = { workspace = true, features = [
"agent-schedules", "agent-schedules",
"agent-inference", "agent-inference",
"agent-permissions",
] } ] }
# This agent's own queue secret, read from the store under its own certificate. # This agent's own queue secret, read from the store under its own certificate.
swarm-secret-client.workspace = true 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 - **`login.rs` / `login_session.rs`** — first-run and session-resume
auth flow for the `claude` CLI. auth flow for the `claude` CLI.
- **`mcp_config.rs`** — renders the per-turn `--mcp-config` / - **`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 - **`todos.rs` / `reminders.rs` / `todo_server.rs`** — the harness-local
loose-ends v2 stores (sqlite-backed) and the in-agent socket server loose-ends v2 stores (sqlite-backed) and the in-agent socket server
extra MCP daemons + `hive-agent-mcp` dial into for todo/reminder ops. 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_agent_state;
mod swarm_inference; mod swarm_inference;
mod swarm_input; mod swarm_input;
mod swarm_permissions;
mod swarm_queue; mod swarm_queue;
mod swarm_term; mod swarm_term;
mod term_msg; mod term_msg;
@ -496,6 +497,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
swarm_agent_icon::spawn(); swarm_agent_icon::spawn();
// The model and effort the swarm sets for this agent, over its local ones. // The model and effort the swarm sets for this agent, over its local ones.
tokio::spawn(swarm_inference::run(bus.clone())); 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- // 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 // cleared by `handle_turn` before building the next wake prompt — see
// `hive_sh4re::inbox::INTERRUPTED_HINT`. Shared between the web server task and // `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 //! set into the `--allowedTools` / `--tools` argument strings and renders the
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server + //! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
//! any `services.hyperhive.agent.extraMcpServers`). Pure config-string generation consumed by //! 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 //! running MCP server (a separate binary) — the `send` allow-list check that
//! server enforces lives alongside it in the `hive-agent-mcp` crate. //! 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 /// Name of the hyperhive MCP server inside claude's view. Claude prefixes
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`). /// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
pub const SERVER_NAME: &str = "hyperhive"; 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). /// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790; pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
/// The built-in tool surface — the base list, the group-gated additions, the /// The value for claude's `--tools` flag this turn: which built-in tools exist
/// `HIVE_TOOL_GROUPS` parse, and the `--tools` value they resolve to — lives /// in the session at all, from the agent's current permissions. The
/// in `hive_sh4re::permissions`, next to `ToolGroup` itself. Re-exported here /// resolution lives in `hive_sh4re::permissions` because the subagent daemon
/// because this module is where the rest of the claude launch config is /// (`hive-subagent-mcp`) spawns its own `claude` from the same one.
/// assembled, and because the subagent daemon (`hive-subagent-mcp`) spawns #[must_use]
/// its own `claude` from the same base resolution (plus its own `Bash` pub fn builtin_tools_arg() -> String {
/// exception — see `hive_sh4re::permissions::subagent_builtin_tools_arg`): hive_sh4re::permissions::builtin_tools_for(&crate::swarm_permissions::current()).join(",")
/// `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};
/// 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 /// Most `services.hyperhive.agent.extraMcpServers` entries are ungated — available whenever
/// the operator declares them. The `bash` server is the exception: raw shell /// 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 is a privilege, so it is only exposed when the agent holds
/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at /// `Execution`. Unlike the in-process hyperhive tools (gated at dispatch),
/// dispatch) and the capability tools (re-checked server-side by hive-c0re),
/// an out-of-process server has **no** later enforcement point — once it is /// 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 /// 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. /// 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 { match server {
"bash" => Some(hive_sh4re::permissions::ToolGroup::Execution), "bash" => Some(Permission::Execution),
_ => None, _ => None,
} }
} }
/// Whether an extra MCP server should be exposed to claude given the active /// Whether an extra MCP server should be exposed to claude given the agent's
/// tool `groups`. A gated server (see [`extra_server_required_group`]) is /// `perms`. A gated server (see [`extra_server_required_permission`]) is
/// suppressed when the agent lacks its required group. /// suppressed when the agent lacks its required permission.
fn extra_server_enabled(server: &str, groups: &[hive_sh4re::permissions::ToolGroup]) -> bool { fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool {
extra_server_required_group(server).is_none_or(|required| groups.contains(&required)) extra_server_required_permission(server).is_none_or(|required| perms.contains(&required))
} }
#[cfg(test)] #[cfg(test)]
mod extra_server_gate_tests { mod extra_server_gate_tests {
use super::{extra_server_enabled, extra_server_required_group}; use super::{extra_server_enabled, extra_server_required_permission};
use hive_sh4re::permissions::ToolGroup; use hive_sh4re::permissions::Permission;
#[test] #[test]
fn bash_is_gated_behind_execution() { fn bash_is_gated_behind_execution() {
assert_eq!( assert_eq!(
extra_server_required_group("bash"), extra_server_required_permission("bash"),
Some(ToolGroup::Execution) Some(Permission::Execution)
); );
// Suppressed without Execution, even if other groups are present. // Suppressed without Execution, even if other groups are present.
assert!(!extra_server_enabled( assert!(!extra_server_enabled(
"bash", "bash",
&[ToolGroup::Messaging, ToolGroup::Inbox] &[Permission::Messaging, Permission::Inbox]
)); ));
// Available once Execution is granted. // Available once Execution is granted.
assert!(extra_server_enabled("bash", &[ToolGroup::Execution])); assert!(extra_server_enabled("bash", &[Permission::Execution]));
} }
#[test] #[test]
fn other_servers_are_ungated() { fn other_servers_are_ungated() {
assert_eq!(extra_server_required_group("matrix"), None); assert_eq!(extra_server_required_permission("matrix"), None);
assert_eq!(extra_server_required_group("scraper"), None); assert_eq!(extra_server_required_permission("scraper"), None);
// An ungated server is available regardless of (even empty) groups. // An ungated server is available regardless of (even empty) groups.
assert!(extra_server_enabled("matrix", &[])); 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 /// MCP tools claude is allowed to call without prompting, derived from
/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl /// the supplied permissions. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re /// requires updating the matching `Permission::tools()` slice in hive-sh4re
/// (single source of truth). See `docs/process/conventions.md::Tool groups`. /// (single source of truth).
#[must_use] #[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. // Collect all tool names, deduplicating while preserving order.
// Always-on tools (e.g. `set_status`) come first so they're present // 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. // agent still has to be able to report its dashboard status.
let mut seen = std::collections::HashSet::new(); 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() .iter()
.copied() .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)) .filter(|t| seen.insert(*t))
.map(|t| format!("mcp__{SERVER_NAME}__{t}")) .map(|t| format!("mcp__{SERVER_NAME}__{t}"))
.collect(); .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) // them without per-tool operator approval. `["*"]` (the default)
// expands to `mcp__<server>__*` — every tool from that server. // expands to `mcp__<server>__*` — every tool from that server.
for (server, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() { 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; continue;
} }
for pat in spec.allowed_tools() { 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. /// both the built-ins and the MCP surface.
#[must_use] #[must_use]
pub fn allowed_tools_arg() -> String { 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 // The same built-ins `--tools` makes exist this session, so a tool that
// exists is never one claude has to prompt about. // 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() .into_iter()
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.collect(); .collect();
all.extend(allowed_mcp_tools(&groups)); all.extend(allowed_mcp_tools(&perms));
all.join(",") 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 /// The set of MCP server names claude is configured with this turn — the
/// keys of the rendered `--mcp-config` (built-in hyperhive HTTP surface + /// 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 /// this against the per-turn `system`/`init` event's `mcp_servers` to
/// detect a configured server that failed to connect or was dropped /// detect a configured server that failed to connect or was dropped
/// (the MCP-health instrumentation). /// (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 /// 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 /// servers (stdio or http, per each entry's `type`). Shared by
/// [`render_claude_config`] (serialises it) and [`configured_server_names`] /// [`render_claude_config`] (serialises it) and [`configured_server_names`]
/// (lists its keys) so the two never drift. /// (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 // (Http entries have no child-process env to inject into — the daemon
// behind the URL resolves its own state dir independently.) // behind the URL resolves its own state dir independently.)
let state_dir = crate::paths::state_dir(); 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 // 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 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). // 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() { for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if name == SERVER_NAME { if name == SERVER_NAME {
tracing::warn!( tracing::warn!(
@ -199,10 +197,10 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
); );
continue; continue;
} }
if !extra_server_enabled(&name, &groups) { if !extra_server_enabled(&name, &perms) {
tracing::info!( tracing::info!(
server = %name, server = %name,
"extra MCP server suppressed: agent lacks the required tool group" "extra MCP server suppressed: agent lacks the required permission"
); );
continue; continue;
} }
@ -214,7 +212,7 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{SERVER_NAME, allowed_mcp_tools}; use super::{SERVER_NAME, allowed_mcp_tools};
use hive_sh4re::permissions::ToolGroup; use hive_sh4re::permissions::Permission;
fn qualified(tool: &str) -> String { fn qualified(tool: &str) -> String {
format!("mcp__{SERVER_NAME}__{tool}") format!("mcp__{SERVER_NAME}__{tool}")
@ -222,7 +220,7 @@ mod tests {
#[test] #[test]
fn set_status_present_with_no_groups() { 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. // `meta`) must still be able to report its dashboard status.
let tools = allowed_mcp_tools(&[]); let tools = allowed_mcp_tools(&[]);
assert!( assert!(
@ -234,7 +232,7 @@ mod tests {
#[test] #[test]
fn mark_todos_done_present_with_no_groups() { fn mark_todos_done_present_with_no_groups() {
// Regression: mark_todos_done was declared as a // 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 — // get it into --allowedTools regardless of which groups it held —
// including `inbox`, which only gates get_loose_ends/ // including `inbox`, which only gates get_loose_ends/
// cancel_loose_end/remind. // cancel_loose_end/remind.
@ -243,13 +241,13 @@ mod tests {
tools.contains(&qualified("mark_todos_done")), tools.contains(&qualified("mark_todos_done")),
"mark_todos_done missing from empty-group allow-list: {tools:?}" "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"))); assert!(tools.contains(&qualified("mark_todos_done")));
} }
#[test] #[test]
fn set_status_present_without_meta_group() { 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"))); assert!(tools.contains(&qualified("set_status")));
// get_agent_meta stays gated behind `meta` — only set_status is always-on. // get_agent_meta stays gated behind `meta` — only set_status is always-on.
assert!(!tools.contains(&qualified("get_agent_meta"))); assert!(!tools.contains(&qualified("get_agent_meta")));
@ -257,7 +255,7 @@ mod tests {
#[test] #[test]
fn no_duplicate_set_status_when_meta_granted() { 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 let count = tools
.iter() .iter()
.filter(|t| **t == qualified("set_status")) .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 /// prompt (`--system-prompt-file`). Static settings are no longer
/// passed here — they live at `/etc/claude-code/managed-settings.json` /// passed here — they live at `/etc/claude-code/managed-settings.json`
/// and claude auto-discovers them. /// and claude auto-discovers them.
/// Materialised once at harness startup; shared between the turn loop /// Materialised at harness startup; shared between the turn loop and the
/// and the operator-driven `/compact` path so both invocations look /// operator-driven `/compact` path so both invocations look identical to
/// identical to claude (same MCP surface, same allowed tools, same /// claude (same MCP surface, same allowed tools, same role prompt — only the
/// role prompt — only the stdin payload differs). /// 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)] #[derive(Clone)]
pub struct TurnFiles { pub struct TurnFiles {
pub mcp_config: PathBuf, pub mcp_config: PathBuf,
@ -114,6 +116,16 @@ impl TurnFiles {
system_prompt: write_system_prompt(socket, label).await?, 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>`. /// 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 { fn acp_permission_policy() -> PermissionPolicy {
let web = std::sync::Arc::new(|ask: &PermissionAsk<'_>| {
mcp_config::effective_tool_groups().contains(&hive_sh4re::permissions::ToolGroup::WebTools); let web = crate::swarm_permissions::current()
std::sync::Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, web)) .contains(&hive_sh4re::permissions::Permission::WebTools);
acp_permits(ask, web)
})
} }
/// Whether an ACP agent may run the tool call it asks about. Default-deny, /// Whether an ACP agent may run the tool call it asks about. Default-deny,
/// allowing what a claude agent has: /// allowing what a claude agent has:
/// ///
/// - `other`-kind calls to tools of the MCP servers the session was handed /// - `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`); /// (`mcp_config`);
/// - the file tools, mirroring the claude built-ins /// - the file tools, mirroring the claude built-ins
/// (`hive_sh4re::permissions`): `read`, `edit`, `search`; /// (`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`) — /// Everything else is refused, including a built-in shell (`execute`) —
/// shell goes through `mcp__bash__run` — and any kind this list doesn't name. /// 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. // Heuristic: context large AND prompt cache gone cold.
maybe_auto_reset(bus, session); maybe_auto_reset(bus, session);
} }
files.refresh_mcp_config().await;
let config = claude_config(bus, files); let config = claude_config(bus, files);
let sink = BusSink::new(bus); let sink = BusSink::new(bus);
let mut result = session.run(&config, prompt, &sink).await; 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(), text: "operator: /compact — running on idle session".into(),
}); });
bus.set_state(crate::events::TurnState::Compacting); bus.set_state(crate::events::TurnState::Compacting);
files.refresh_mcp_config().await;
let config = claude_config(bus, files); let config = claude_config(bus, files);
let sink = BusSink::new(bus); let sink = BusSink::new(bus);
report_compact(bus, session.compact(&config, &sink).await); report_compact(bus, session.compact(&config, &sink).await);

View file

@ -1,17 +1,16 @@
//! Per-agent configuration registries: tool groups, resource limits, //! Per-agent configuration registries: resource limits, topology (JSON files
//! topology (JSON files under `/var/lib/hyperhive/meta/`), the capabilities //! under `/var/lib/hyperhive/meta/`), the permissions the swarm grants
//! the swarm grants (outside the meta repo) and the shared wire-protocol size //! (outside the meta repo) and the shared wire-protocol size limits. Each
//! limits. Each submodule is re-exported at the crate root, so //! submodule is re-exported at the crate root, so `crate::topology::…` etc.
//! `crate::topology::…` etc. keep working unchanged. //! keep working unchanged.
//! //!
//! Note the two similarly-named modules: [`limits`] caps inline //! Note the two similarly-named modules: [`limits`] caps inline
//! *message body* sizes on the sockets, while [`resource_limits`] holds //! *message body* sizes on the sockets, while [`resource_limits`] holds
//! per-agent CPU/memory caps for the container drop-in. //! per-agent CPU/memory caps for the container drop-in.
pub mod capabilities;
pub mod limits; pub mod limits;
pub mod permissions;
pub mod resource_limits; pub mod resource_limits;
pub mod tool_groups;
pub mod topology; pub mod topology;
use std::io::{self, Write as _}; 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 //! `agent-permissions` bucket by `workers::swarm_permissions`. Stored at
//! `/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo: //! `/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. //! the swarm is the record, this file is the hive's last read of it.
//! //!
//! Format: agent name → the stored //! Format: agent name → the stored
//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with //! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with
//! no entry, or an entry with `capabilities` unset, holds its default //! no entry, or an entry with `permissions` unset, holds
//! ([`default_for`]); `"capabilities": []` holds none. //! [`Permission::default_for`]; `"permissions": []` holds none.
//! //!
//! Read path: `lifecycle::host_config::set_nspawn_flags`, on every container //! hive-c0re enforces one member, [`Permission::ManageRootAgent`]: read by
//! start, which is why a change takes a restart. //! `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::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use hive_sh4re::permissions::Capability; use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::AgentPermissions; use swarm_queue_client::agent_permissions::AgentPermissions;
const FILE: &str = "swarm-agent-permissions.json"; 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) super::write_map(path, stored)
} }
/// The capabilities an agent holds when the swarm sets none: `ruth` recovers /// The permissions `agent` holds under `record`.
/// the other agents, every other agent holds nothing.
#[must_use] #[must_use]
pub fn default_for(agent: &str) -> Vec<Capability> { pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Permission> {
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> {
record record
.and_then(|r| r.capabilities.clone()) .and_then(|r| r.permissions.clone())
.unwrap_or_else(|| default_for(agent)) .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 /// # Errors
/// ///
/// As [`read_at`]. /// As [`read_at`].
pub fn holds(path: &Path, agent: &str, cap: Capability) -> std::io::Result<bool> { pub fn holds(path: &Path, agent: &str, perm: Permission) -> std::io::Result<bool> {
Ok(effective(agent, read_at(path)?.get(agent)).contains(&cap)) Ok(effective(agent, read_at(path)?.get(agent)).contains(&perm))
} }
#[cfg(test)] #[cfg(test)]
@ -75,19 +66,16 @@ mod tests {
#[test] #[test]
fn unset_is_the_default_and_an_empty_list_is_none() { fn unset_is_the_default_and_an_empty_list_is_none() {
assert_eq!(effective("ruth", None), vec![Capability::ManageRootAgent]); assert!(effective("ruth", None).contains(&Permission::ManageRootAgent));
assert_eq!( assert!(effective("ruth", Some(&record("{}"))).contains(&Permission::ManageRootAgent));
effective("ruth", Some(&record("{}"))), assert!(effective("ruth", Some(&record(r#"{"permissions":[]}"#))).is_empty());
vec![Capability::ManageRootAgent] assert_eq!(effective("atlas", None), Permission::DEFAULT.to_vec());
);
assert!(effective("ruth", Some(&record(r#"{"capabilities":[]}"#))).is_empty());
assert!(effective("atlas", None).is_empty());
assert_eq!( assert_eq!(
effective( effective(
"atlas", "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() { fn a_missing_file_holds_the_defaults() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE); let path = dir.path().join(FILE);
assert!(holds(&path, "ruth", Capability::ManageRootAgent).expect("missing is empty")); assert!(holds(&path, "ruth", Permission::ManageRootAgent).expect("missing is empty"));
assert!(!holds(&path, "atlas", Capability::ManageRootAgent).expect("missing is empty")); assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("missing is empty"));
} }
#[test] #[test]
fn a_corrupt_file_is_an_error() { fn a_corrupt_file_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE); let path = dir.path().join(FILE);
std::fs::write(&path, "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag").expect("seed"); std::fs::write(&path, "{\n \"ruth\": {\"permissions\": [\"manage_root_ag").expect("seed");
holds(&path, "ruth", Capability::ManageRootAgent).expect_err("truncated JSON"); holds(&path, "ruth", Permission::ManageRootAgent).expect_err("truncated JSON");
} }
} }

View file

@ -3,7 +3,7 @@
//! //!
//! The swarm caps are stored at //! The swarm caps are stored at
//! `/var/lib/hyperhive/meta/swarm-resource-limits.json` alongside //! `/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 //! Format: a JSON object mapping agent name to an object with optional
//! `cpu_quota` / `memory_max` strings, passed verbatim to systemd's //! `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 //! 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 //! *for* now is the one thing that survived the removal — naming every
//! agent, which is the set a //! agent, which is the set a
//! [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets //! [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets
//! bind-mounted ([`all_agents`]). //! bind-mounted ([`all_agents`]).
//! //!
//! Broader-than-your-own bind-mount grants are **not** stored here: they //! 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 //! (`swarm_permissions`). See
//! `lifecycle::set_nspawn_flags`. //! `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 /// 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 /// bind-mounted, and it is deliberately unfiltered: that capability means
/// "may manage any agent", so the set is all of them. /// "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 /// Subscribe to the shutdown watch channel. Background tasks call
/// this at spawn time and break their loop when the receiver /// this at spawn time and break their loop when the receiver
/// transitions to `true` (via `Coordinator::request_shutdown`). /// 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 = "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 = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"), (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 = "rebuild_queue", description = "rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "state_snapshot", description = "cold-load dashboard snapshot"),
@ -56,7 +55,6 @@ mod health;
mod lifecycle_ops; mod lifecycle_ops;
mod meta_inputs; mod meta_inputs;
mod misc_api; mod misc_api;
pub(crate) mod permissions;
mod rebuild_queue; mod rebuild_queue;
mod state_files; mod state_files;
mod state_snapshot; 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_logs_agent))
.routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw)) .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!(rebuild_queue::post_rebuild_queue_cancel))
.routes(routes!(lifecycle_ops::post_destroy)) .routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill)) .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_logs_agent))
.routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw)) .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!(rebuild_queue::post_rebuild_queue_cancel))
.routes(routes!(lifecycle_ops::post_destroy)) .routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill)) .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 /// (transients, container state), and a poll loop would be
/// slower for the same information. /// slower for the same information.
RebuildQueueChanged { seq: u64 }, 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 { impl DashboardEvent {
@ -185,7 +167,6 @@ impl DashboardEvent {
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
} }
} }
} }
@ -260,14 +241,6 @@ mod tests {
running: false, running: false,
}, },
DashboardEvent::RebuildQueueChanged { seq: 1 }, 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 { for ev in samples {
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); 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 // The payload rides the node and is destructured here, so the executor
// takes it directly instead of re-matching the kind behind a `bail!` // takes it directly instead of re-matching the kind behind a `bail!`
// that could never fire. // that could never fire.
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
NodeKind::EmitRebuilt { ok, .. } => { NodeKind::EmitRebuilt { ok, .. } => {
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await; run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
Ok(()) Ok(())
@ -790,30 +789,6 @@ async fn run_write_dropin(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
Ok(()) 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 /// Compute which agents a `nix flake update <inputs>` on the meta
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty /// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
/// `inputs` or any input under `hyperhive` → every container; /// `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 hive_jobq_wire::{GraphNode, GraphWire};
use tokio::sync::Notify; use tokio::sync::Notify;
pub use model::{NodeKind, PermPayload, State}; pub use model::{NodeKind, State};
use resource::Resource; use resource::Resource;
/// A job under construction: `hive_jobq`'s builder over this queue's payload /// 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 //! Data model for the generic job-DAG queue: the node kinds — the primitive
//! operations — and what each one carries. The `State` / `PermPayload` wire //! operations — and what each one carries. The `State` wire
//! enums live in `hive_host_sock::jobs` (they travel on the host admin //! enum lives in `hive_host_sock::jobs` (it travels on the host admin
//! socket) and are re-exported here for the queue's internal use. The graph //! 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 //! itself is served through `hive_jobq_wire`'s generic projection — there is
//! no second, typed view of it any more. //! 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` //! job is exactly the nodes it declared. See `docs/scheduler/coordinator.md::Job queue`
//! for the full design. //! for the full design.
pub use hive_host_sock::jobs::{PermPayload, State}; pub use hive_host_sock::jobs::State;
use serde::Serialize; use serde::Serialize;
/// The primitive operations — each kind maps to one executor fn in /// The primitive operations — each kind maps to one executor fn in
@ -98,12 +98,10 @@ pub enum NodeKind {
PauseDrain { agent: String }, PauseDrain { agent: String },
/// `set_nspawn_flags` + `set_resource_limits` + daemon-reload. /// `set_nspawn_flags` + `set_resource_limits` + daemon-reload.
WriteDropin { agent: String }, 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 /// Group root of a rebuild subtree — the **brace**, scoped to one
/// agent. Why braces exist and what they cost: _Braces_. /// agent. Why braces exist and what they cost: _Braces_.
AgentWindow { agent: String }, 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 /// manager event. One node per agent *and* per outcome; no cancel
/// variant (a cancelled DAG never ran, so there is no rebuild to /// variant (a cancelled DAG never ran, so there is no rebuild to
/// report). /// report).
@ -185,7 +183,6 @@ impl NodeKind {
| NodeKind::PauseSignal { agent } | NodeKind::PauseSignal { agent }
| NodeKind::PauseDrain { agent } | NodeKind::PauseDrain { agent }
| NodeKind::WriteDropin { agent } | NodeKind::WriteDropin { agent }
| NodeKind::WritePermFile { agent, .. }
| NodeKind::AgentWindow { agent } | NodeKind::AgentWindow { agent }
| NodeKind::EmitRebuilt { agent, .. } | NodeKind::EmitRebuilt { agent, .. }
| NodeKind::SetWanted { agent, .. } => agent, | NodeKind::SetWanted { agent, .. } => agent,

View file

@ -20,7 +20,7 @@
//! `lifecycle::is_running` read), so [`super::power`] assembles them out of //! `lifecycle::is_running` read), so [`super::power`] assembles them out of
//! the primitives this module exports ([`rebuild_nodes`]). //! the primitives this module exports ([`rebuild_nodes`]).
use super::model::{NodeKind, PermPayload}; use super::model::NodeKind;
use super::resource::Resource; use super::resource::Resource;
use super::{Handle, JobBuilder}; use super::{Handle, JobBuilder};
@ -403,26 +403,6 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
.after_ok(last); .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 /// Meta-input lock bump. The `MetaLock` executor grows one rebuild subgraph
/// per affected agent into *this same* DAG on completion (declared onto the /// per affected agent into *this same* DAG on completion (declared onto the
/// builder it was handed) — appended *after* the bump lands so their prebuilds /// 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 ---- // ---- standalone sweeps ----
#[test] #[test]

View file

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

View file

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

View file

@ -109,7 +109,7 @@ pub fn spawn(
shutdown.clone(), 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( tokio::spawn(crate::workers::swarm_permissions::follow(
client.clone(), client.clone(),
std::sync::Arc::clone(&coord), std::sync::Arc::clone(&coord),

View file

@ -23,7 +23,6 @@ use anyhow::Result;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; 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 /// Resolve the current rev of `hyperhive_flake`. For a path on disk we
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/... /// 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 hive = coord.hive_env();
let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?; let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?;
lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?; lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?;
seed_manager_tool_groups();
if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) { if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) {
tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed"); 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(()) 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 /// Boot reconcile (see the module doc): classify every agent by rev
/// freshness + persisted `wanted` intent, submit one `Boot` DAG /// freshness + persisted `wanted` intent, submit one `Boot` DAG
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale /// (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 //! The one-shot move of the permissions this hive's meta repo held
//! (`meta/capabilities.json`) into the swarm's `agent-permissions` bucket. //! (`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 //! At boot, before any agent starts, [`seed_local`] copies the files' grants
//! into [`crate::capabilities`] when nothing is stored there yet, so agents //! into [`crate::permissions`] when nothing is stored there yet, so agents
//! keep their mounts until the swarm answers. [`send_pending`] then sends the //! 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 //! 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 //! is none, and on its answer deletes both files from the meta repo. Nothing
//! reads the file after that. //! reads them after that.
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hive_sh4re::permissions::Capability; use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{ use swarm_queue_client::agent_permissions::{
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply, self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
}; };
/// The meta repo file the grants are moved out of. const CAPABILITIES_FILE: &str = "capabilities.json";
pub const META_FILE: &str = "capabilities.json"; const TOOL_GROUPS_FILE: &str = "tool-groups.json";
fn meta_path() -> PathBuf { /// The meta repo files the grants are moved out of.
crate::paths::meta_root().join(META_FILE) pub const META_FILES: &[&str] = &[CAPABILITIES_FILE, TOOL_GROUPS_FILE];
/// 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(|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");
}
found
} }
/// The grants the meta file holds, by agent, as the swarm stores them. A name /// The one list per agent the two files add up to, as the swarm stores it.
/// that is not a [`Capability`] is dropped, with a warning. /// `None` for either file means it does not exist.
/// ///
/// `ruth` with no entry in a file that exists had her grant revoked: the file /// Each half is what the agent held under the old readers:
/// was created holding it, and a revoke removed the entry. She is sent an /// - tool groups: the entry's known names; an absent entry, or one naming
/// empty list, since unset would give her the default back. /// only unknown names, was the default groups. `lifecycle`/`forge` count as
fn grants(file: BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<Capability>> { /// known (they parsed) and are then dropped, so an entry of only those
let mut out: BTreeMap<String, Vec<Capability>> = file /// held no tools and imports as none.
.into_iter() /// - capabilities: the entry's names. With the file present, `ruth` having no
.map(|(agent, names)| { /// entry means her grant was revoked: the file was created holding it, and
let caps = names /// 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();
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() .iter()
.filter_map(|name| { .copied()
let cap = Capability::ALL .filter(|p| *p == Permission::ManageRootAgent)
.iter() .collect(),
.copied() };
.find(|c| name.eq_ignore_ascii_case(<&str>::from(*c))); let mut list: Vec<Permission> = Vec::new();
if cap.is_none() { for perm in tool_part.into_iter().chain(cap_part) {
tracing::warn!(%agent, capability = %name, "import: dropping unrecognised capability"); if !list.contains(&perm) {
} list.push(perm);
cap }
}) }
.collect(); let same_as_default =
(agent, caps) list.len() == default.len() && default.iter().all(|p| list.contains(p));
}) if !same_as_default {
.collect(); out.insert(agent.to_owned(), list);
out.entry(crate::lifecycle::MANAGER_NAME.to_owned()) }
.or_default(); }
out out
} }
fn read_grants(path: &Path) -> Result<Option<BTreeMap<String, Vec<Capability>>>> { fn read_file(path: &Path) -> Result<Option<FileMap>> {
if !path.exists() { if !path.exists() {
return Ok(None); 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()))?, &std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
) )
.with_context(|| format!("parsing {}", 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 /// The grants the files in `meta` hold, or `None` when neither exists.
/// file is still there and nothing has been stored yet. 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() { 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"); 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 let records = grants
.into_iter() .into_iter()
.map(|(agent, caps)| { .map(|(agent, perms)| {
( (
agent, agent,
AgentPermissions { AgentPermissions {
capabilities: Some(caps), permissions: Some(perms),
}, },
) )
}) })
.collect(); .collect();
crate::capabilities::write_at(stored, &records).context("storing the seeded permissions")?; crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo's capabilities"); tracing::info!("import: seeded the stored permissions from the meta repo");
Ok(()) Ok(())
} }
/// Send the meta file's grants to swarm-controller while the file is there, /// What one attempt to send the import came to.
/// and delete it once the controller has answered that it handled them. #[derive(Debug)]
/// pub enum ImportOutcome {
/// # Errors /// No meta file is left: nothing to import.
/// NothingPending,
/// The file is still there: the import failed, or deleting the file did. The /// The controller took the grants and the files are deleted.
/// next pass sends again; the controller keeps a key it already has. Imported,
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> Result<()> { /// The controller answered with a refusal; the files stay.
send_at(client, hive, &meta_path()) Refused(String),
.await /// No answer (the controller or the queue is down), or a file could not
.context("importing the meta repo's capabilities") /// be read or deleted; the files stay.
Unsettled(anyhow::Error),
} }
async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result<()> { impl ImportOutcome {
let Some(capabilities) = read_grants(path)? else { /// Whether the swarm's keys may be read after this outcome. While a file
return Ok(()); /// 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)?; 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( let reply = tokio::time::timeout(
IMPORT_REPLY_DEADLINE, IMPORT_REPLY_DEADLINE,
client.request(agent_permissions::import_subject(hive), payload.into()), 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, ?written,
?kept, ?kept,
?skipped, ?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 .await
.context("deleting the imported capabilities file") .context("deleting the imported permission files")?;
} Ok(ImportOutcome::Imported)
ImportReply::Failed { error } => {
anyhow::bail!("the controller refused the import: {error}")
} }
ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)),
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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}; fn file(json: &str) -> FileMap {
use hive_sh4re::permissions::Capability;
fn file(json: &str) -> BTreeMap<String, Vec<String>> {
serde_json::from_str(json).expect("decodes") 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] #[test]
fn grants_keep_known_names_and_a_revoked_ruth_stays_revoked() { fn the_two_files_merge_into_one_list() {
let out = grants(file( let caps = file(r#"{"ruth": ["manage_root_agent"], "atlas": ["manage_root_agent"]}"#);
r#"{"atlas": ["manage_root_agent", "fly_to_the_moon"], "iris": []}"#, let groups = file(
)); r#"{"ruth": ["messaging", "meta", "inbox", "lifecycle", "execution"],
assert_eq!(out["atlas"], vec![Capability::ManageRootAgent]); "atlas": ["messaging", "web_tools", "forge"],
assert_eq!(out["iris"], Vec::<Capability>::new()); "iris": ["messaging", "fly_to_the_moon"],
assert_eq!(out["ruth"], Vec::<Capability>::new()); "sock": ["lifecycle"]}"#,
);
let out = grants(file(r#"{"ruth": ["manage_root_agent"]}"#)); let out = grants(Some(&caps), Some(&groups));
assert_eq!(out["ruth"], vec![Capability::ManageRootAgent]); 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] #[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 dir = tempfile::tempdir().expect("tempdir");
let meta = dir.path().join("capabilities.json"); let meta = dir.path();
let stored = dir.path().join("stored.json"); 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()); assert!(!stored.exists());
std::fs::write(&meta, r#"{"atlas": ["manage_root_agent"]}"#).expect("seed"); std::fs::write(
seed_local_at(&meta, &stored).expect("seeded"); meta.join("capabilities.json"),
assert!( r#"{"atlas": ["manage_root_agent"]}"#,
crate::capabilities::holds(&stored, "atlas", Capability::ManageRootAgent) )
.expect("read") .expect("seed");
); seed_local_at(meta, &stored).expect("seeded");
assert!( assert!(crate::permissions::holds(&stored, "atlas", ManageRootAgent).expect("read"));
!crate::capabilities::holds(&stored, "ruth", Capability::ManageRootAgent) assert!(!crate::permissions::holds(&stored, "ruth", ManageRootAgent).expect("read"));
.expect("read")
);
std::fs::write(&stored, "{}").expect("a later 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"), "{}"); 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-controller keeps one key per agent in the `agent-permissions` bucket
//! ([`swarm_queue_client::agent_permissions`]). Every [`READ_INTERVAL`] this //! ([`swarm_queue_client::agent_permissions`]). Every [`READ_INTERVAL`] this
//! reads the key of each agent this hive has and stores what it finds with //! 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. //! [`crate::permissions`], where the container's bind mounts are worked out.
//! Those are fixed when the container starts, so an agent whose capabilities //! Those are fixed when the container starts, so an agent whose
//! changed is restarted, after its current turn. //! `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 //! 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 //! permissions as they are and restarts nothing.
//! agent back on its default, which can be wider than what the swarm set.
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read}; use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read};
use crate::capabilities::{self, Stored};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::permissions::{self, Stored};
use crate::stats::sweep_health::{self, SweepHealth}; use crate::stats::sweep_health::{self, SweepHealth};
/// How often the permissions are read. /// 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); let mut health = SweepHealth::new("swarm_permissions_read", "warn", FAILURES_BEFORE_BANNER);
loop { loop {
// While the meta repo's grants are not in the swarm yet, an unset key let import = super::permissions_import::send_pending(&client, &hive).await;
// is not the swarm's answer, so nothing is read until they are. let pass = if import.read_allowed() {
let pass = match super::permissions_import::send_pending(&client, &hive).await { sync(&client).await
Ok(()) => sync(&client).await, } else {
Err(e) => Err(e), Err(import
.into_error()
.unwrap_or_else(|| anyhow::anyhow!("import unsettled")))
}; };
match pass { match pass {
Ok(changed) => { Ok(changed) => {
@ -76,7 +79,7 @@ pub async fn follow(
} }
/// Read every agent's key and store the result. Returns the agents whose /// 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>> { async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
// An unconnected client does not fail a JetStream request, it hangs on it. // An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client)?; swarm_queue_client::ensure_connected(client)?;
@ -86,7 +89,7 @@ async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
.into_iter() .into_iter()
.map(|spec| spec.name) .map(|spec| spec.name)
.collect(); .collect();
sync_with(&agents, &capabilities::path(), |agent| async move { sync_with(&agents, &permissions::path(), |agent| async move {
agent_permissions::read(client, &agent) agent_permissions::read(client, &agent)
.await .await
.map_err(|e| anyhow::anyhow!(e)) .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 { if read_now == stored {
return Ok(Vec::new()); 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"); tracing::info!(permissions = ?read_now, "swarm permissions: stored the swarm's permissions");
Ok(agents Ok(agents
.iter() .iter()
.filter(|a| { .filter(|a| manages(a, &stored) != manages(a, &read_now))
capabilities::effective(a, stored.get(*a))
!= capabilities::effective(a, read_now.get(*a))
})
.cloned() .cloned()
.collect()) .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 /// 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. /// stopped agent gets them at its next start.
async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) { async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
let mut running = Vec::new(); let mut running = Vec::new();
@ -145,7 +150,7 @@ async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
if running.is_empty() { if running.is_empty() {
return; 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 { 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"); tracing::error!(agents = ?running, error = ?e, "swarm permissions: restart insert failed");
} }
@ -188,16 +193,12 @@ mod tests {
.await .await
.expect("read"); .expect("read");
assert!(changed.is_empty()); assert!(changed.is_empty());
assert!(crate::permissions::read_at(&path).expect("read").is_empty());
assert!( assert!(
crate::capabilities::read_at(&path) crate::permissions::holds(
.expect("read")
.is_empty()
);
assert!(
crate::capabilities::holds(
&path, &path,
"ruth", "ruth",
hive_sh4re::permissions::Capability::ManageRootAgent hive_sh4re::permissions::Permission::ManageRootAgent
) )
.expect("read") .expect("read")
); );
@ -206,14 +207,14 @@ mod tests {
/// Revoking ruth's default, and granting atlas, restarts both; clearing /// Revoking ruth's default, and granting atlas, restarts both; clearing
/// them again restarts both back. /// them again restarts both back.
#[tokio::test] #[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 dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json"); let path = dir.path().join("p.json");
let changed = sync(&path, |agent| { let changed = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" { Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"# r#"{"permissions":[]}"#
} else { } else {
r#"{"capabilities":["manage_root_agent"]}"# r#"{"permissions":["manage_root_agent"]}"#
}))) })))
}) })
.await .await
@ -223,9 +224,9 @@ mod tests {
// The same answer again changes nothing. // The same answer again changes nothing.
let unchanged = sync(&path, |agent| { let unchanged = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" { Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"# r#"{"permissions":[]}"#
} else { } else {
r#"{"capabilities":["manage_root_agent"]}"# r#"{"permissions":["manage_root_agent"]}"#
}))) })))
}) })
.await .await
@ -238,37 +239,37 @@ mod tests {
assert_eq!(cleared, agents()); assert_eq!(cleared, agents());
} }
/// Setting ruth's default explicitly is a different record but the same /// A change that leaves `manage_root_agent` where it was is stored and
/// mounts, so it is stored and restarts nothing. /// restarts nothing: the agent applies tool permissions itself.
#[tokio::test] #[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 dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json"); let path = dir.path().join("p.json");
let changed = sync(&path, |agent| { let changed = sync(&path, |agent| {
Ok(Read::Current(if agent == "ruth" { Ok(Read::Current(if agent == "ruth" {
record(r#"{"capabilities":["manage_root_agent"]}"#) record(r#"{"permissions":["manage_root_agent"]}"#)
} else { } else {
AgentPermissions::default() record(r#"{"permissions":["messaging","web_tools"]}"#)
})) }))
}) })
.await .await
.expect("read"); .expect("read");
assert!(changed.is_empty()); assert!(changed.is_empty());
assert!( assert!(
crate::capabilities::read_at(&path) crate::permissions::read_at(&path)
.expect("read") .expect("read")
.contains_key("ruth") .contains_key("atlas")
); );
} }
/// One failed read, or a bucket that does not answer, keeps what was /// 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] #[tokio::test]
async fn a_failed_read_keeps_the_last_value() { async fn a_failed_read_keeps_the_last_value() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json"); let path = dir.path().join("p.json");
let revoked = || Ok(Read::Current(record(r#"{"capabilities":[]}"#))); let revoked = || Ok(Read::Current(record(r#"{"permissions":[]}"#)));
// An empty list is atlas's default already, so only ruth changes. // atlas holds no manage_root_agent either way, so only ruth changes.
assert_eq!( assert_eq!(
sync(&path, |_| revoked()).await.expect("read"), sync(&path, |_| revoked()).await.expect("read"),
vec!["ruth".to_owned()] vec!["ruth".to_owned()]

View file

@ -20,7 +20,7 @@ shared payload type some variants reference (`AgentStatusRow`) stays in
client implementation. client implementation.
Its own `jobs` module is the exception: the job-queue vocabulary `hivectl` 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 `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 served as a generic graph (`hive-jobq-wire`), not as a second hand-written
view. view.

View file

@ -1,6 +1,5 @@
//! Vocabulary hive-c0re's job queue shares with its clients: what a //! Vocabulary hive-c0re's job queue shares with its clients: the
//! permission change carries ([`PermPayload`]) and the scheduler's //! scheduler's lifecycle [`State`].
//! lifecycle [`State`].
//! //!
//! A `Source` enum lived here too — where a job came from, rendered as the //! 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 //! "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`. //! scheduling) live in `hive-c0re::job_queue`.
//! Semantics: `docs/scheduler/coordinator.md::Job queue`. //! Semantics: `docs/scheduler/coordinator.md::Job queue`.
use serde::{Deserialize, Serialize};
pub use hive_jobq::State; 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 /// Node id. Carries the scheduler crate's globally-monotonic node id
/// (`hive_jobq::NodeId`) verbatim on the wire — unique across all DAGs, not /// (`hive_jobq::NodeId`) verbatim on the wire — unique across all DAGs, not
/// just within one. Consumers treat it opaquely (grouping + dep matching), /// 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. # `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows.
serde_json.workspace = true serde_json.workspace = true
strum.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 //! Per-agent authorization: the one [`Permission`] set an agent holds.
//! 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.
//! //!
//! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]): //! The set lives in the swarm's `agent-permissions` record
//! that set is group-gated too (`web_tools`), so it belongs next to the //! (`swarm_queue_client::agent_permissions`), one list per agent. Each member
//! groups rather than in any one consumer. Both processes that spawn a //! is enforced where it applies: the MCP-tool and built-in-tool members by
//! `claude` — the harness itself and the subagent daemon — call the same //! the processes that spawn `claude` (the agent harness and the subagent
//! function, so a subagent can never be handed a built-in its parent does //! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An
//! not have — with one deliberate exception: [`subagent_builtin_tools_arg`] //! agent the swarm sets nothing for holds [`Permission::default_for`].
//! additionally grants a **subagent** Claude's own `Bash` when the parent //!
//! holds [`ToolGroup::Execution`]. The parent never gets built-in `Bash` //! It also resolves the *built-in* claude tool list ([`builtin_tools_for`]):
//! itself (it has the out-of-process `bash` MCP server instead); the //! that set is permission-gated too (`web_tools`), so it belongs here rather
//! capability transfers to a subagent, not the mechanism. //! 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
use std::str::FromStr; //! 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}; use serde::{Deserialize, Serialize};
/// Env var the meta renderer writes with a comma-separated list of /// Built-in claude tools present in every session regardless of permissions.
/// [`ToolGroup`] `snake_case` names (e.g. `"messaging,inbox,meta"`). Absent /// Anything not in this list (and not added by a permission's
/// or unparseable means [`ToolGroup::AGENT_DEFAULT`]. See /// [`Permission::builtin_tools`]) literally doesn't exist in the session —
/// `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 —
/// this is the `--tools` list, which holds even under /// this is the `--tools` list, which holds even under
/// `--dangerously-skip-permissions`. /// `--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 /// lives there, off by default. Nested agents (`Task`/`Agent`) are
/// intentionally omitted. `Bash` is disallowed — shell execution goes /// intentionally omitted. `Bash` is disallowed — shell execution goes
/// through `mcp__bash__run` (background tasks with structured output via /// 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. /// matches the task at hand.
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Skill", "Write"]; 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 /// ⚠️ The result is a claude `--tools` value once joined, and must **never be
/// [`ToolGroup::AGENT_DEFAULT`] — the var is written per agent by the meta /// empty** — not because of what an empty value does, but because we don't
/// renderer, so "unset" is an ordinary state, not an error. /// 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] #[must_use]
pub fn effective_tool_groups() -> Vec<ToolGroup> { pub fn builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> {
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> {
let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec(); let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec();
for group in groups { for perm in perms {
for tool in group.builtin_tools() { for tool in perm.builtin_tools() {
if !tools.contains(tool) { if !tools.contains(tool) {
tools.push(tool); tools.push(tool);
} }
@ -71,205 +66,131 @@ pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
tools tools
} }
/// The value for claude's `--tools` flag: which built-in tools exist in this /// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `perms`
/// session at all. /// contains [`Permission::Execution`] — the **subagent**-only `--tools`
///
/// ⚠️ **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`
/// resolution. /// resolution.
/// ///
/// `Execution` already grants shell execution to the parent agent, via the /// `Execution` already grants shell execution to the parent agent, via the
/// out-of-process `bash` MCP server (`mcp__bash__run`/`status`/`kill` — /// 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 /// 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 /// 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` /// it to inherit that permission through; it gets Claude's own `Bash`
/// instead. The capability transfers from the parent, the mechanism it /// instead.
/// arrives by does not have to match.
/// ///
/// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`ToolGroup::builtin_tools`]: /// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`Permission::builtin_tools`]:
/// those are shared with the harness's own `--tools` (`hive-agent`'s /// those are shared with the harness's own `--tools`, and the main agent must
/// `mcp_config::allowed_tools_arg`), and the main agent must never gain /// never gain built-in `Bash` no matter which permissions it holds — only a
/// built-in `Bash` no matter which groups it holds — only a *subagent* /// *subagent* spawned by an `Execution`-holding parent does.
/// spawned by an `Execution`-holding parent does.
#[must_use] #[must_use]
pub fn subagent_builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> { pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> {
let mut tools = builtin_tools_for(groups); let mut tools = builtin_tools_for(perms);
if groups.contains(&ToolGroup::Execution) { if perms.contains(&Permission::Execution) {
tools.push("Bash"); tools.push("Bash");
} }
tools tools
} }
/// The value for a **subagent's** `--tools` flag: [`subagent_builtin_tools_for`] /// One thing an agent may be permitted. Stored by its `snake_case` name.
/// resolved against [`effective_tool_groups`]. See that function for why this #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
/// 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,
)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")] #[strum(serialize_all = "snake_case")]
pub enum ToolGroup { pub enum Permission {
/// `send`, `recv`, `ack_until` /// `send`, `recv`, `ack_until`
Messaging, Messaging,
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`) /// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
Meta, Meta,
/// `get_loose_ends`, `cancel_loose_end`, `remind` /// `get_loose_ends`, `cancel_loose_end`, `remind`
Inbox, 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`/ /// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/
/// `kill`) is rendered into the agent's config at all — see /// `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__*` /// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*`
/// allowlist entry, the gate lives at config-render time instead. /// allowlist entry, the gate lives at config-render time instead.
Execution, Execution,
/// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and /// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and
/// `WebSearch` (search the web). Both are omitted from `--tools` by /// `WebSearch` (search the web). Both are omitted from `--tools`
/// default; adding this group to an agent enables them in the session /// without it; holding it enables them in the session and in
/// and in `--allowedTools` so they run without a confirmation prompt. /// `--allowedTools` so they run without a confirmation prompt.
/// Does not gate any MCP tools — `tools()` returns `&[]`.
WebTools, WebTools,
/// 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`.
///
/// 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,
} }
impl ToolGroup { impl Permission {
/// Parse a comma-separated [`TOOL_GROUPS_ENV`] value. Tokens are trimmed /// Every permission in a stable order.
/// and lowercased; an unrecognised one is warned about and skipped pub const ALL: &'static [Self] = &[
/// rather than failing the whole list, so one stale name in an agent's Self::Messaging,
/// config can't take its whole tool surface away. Self::Meta,
/// Self::Inbox,
/// Returns [`AGENT_DEFAULT`](Self::AGENT_DEFAULT) when `raw` is blank or Self::Execution,
/// names nothing recognisable — the same fallback either way, because Self::WebTools,
/// "the operator granted no groups" and "the var is missing" are not Self::ManageRootAgent,
/// distinguishable here and neither should mean "no tools". ];
/// 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] #[must_use]
pub fn parse_list(raw: &str) -> Vec<Self> { pub fn default_for(agent: &str) -> Vec<Self> {
let mut groups = Vec::new(); let mut perms = Self::DEFAULT.to_vec();
for token in raw.split(',') { if agent == crate::manager::MANAGER_AGENT {
let token = token.trim().to_ascii_lowercase(); perms.push(Self::ManageRootAgent);
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() { perms
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 MCP tool names (without the `mcp__hyperhive__` prefix) in this group. /// The MCP tool names (without the `mcp__hyperhive__` prefix) this
/// Returns `&[]` for `WebTools` (it enables Claude built-in tools, not MCP /// permission grants. Empty for the permissions that grant something
/// tools — see `builtin_tools()`) and for `Lifecycle` / `Forge` (their /// else; see each variant's doc comment.
/// tools were removed); see each variant's doc comment.
#[must_use] #[must_use]
pub fn tools(self) -> &'static [&'static str] { pub fn tools(self) -> &'static [&'static str] {
match self { match self {
Self::Messaging => &["send", "recv", "ack_until"], Self::Messaging => &["send", "recv", "ack_until"],
Self::Meta => &["get_agent_meta"], Self::Meta => &["get_agent_meta"],
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
// All four empty, for different reasons — see each // `Execution` grants the out-of-process `bash` MCP server,
// variant's own doc comment above. `Execution` grants the // gated at config-render time in `hive-agent/src/mcp_config.rs`
// out-of-process `bash` MCP server // — an out-of-process server has no later enforcement point, so
// (`mcp__bash__run`/`status`/`kill`), gated at config-render // that gate is the actual security boundary. `WebTools` grants
// time by `extra_server_required_group` in // Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
// `hive-agent/src/mcp_config.rs`, not by this list — an // bind mounts.
// out-of-process server has no later enforcement point, so Self::Execution | Self::WebTools | Self::ManageRootAgent => &[],
// 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 => &[],
} }
} }
/// MCP tools that are always exposed regardless of which tool groups an /// MCP tools that are always exposed regardless of which permissions an
/// agent is granted. `set_status` lives here because the operator /// agent holds. `set_status` lives here because the operator
/// dashboard depends on every agent being able to report its status /// dashboard depends on every agent being able to report its status
/// chip — gating it behind a group would let a misconfigured agent go /// chip — gating it would let a misconfigured agent go dark on the
/// dark on the dashboard. The server-side `SetStatus` handler has no /// dashboard. The server-side `SetStatus` handler has no permission
/// tool-group check either (only length validation), so listing it here /// check either (only length validation), so listing it here keeps the
/// keeps the `--allowedTools` list honest with that reality. /// `--allowedTools` list honest with that reality.
/// ///
/// `compact` lives here too: it's pure self-management (no cross-agent /// `compact` lives here too: it's pure self-management (no cross-agent
/// effect, no privilege), gated server-side on context usage rather /// effect, no privilege), gated server-side on context usage, and every
/// than on tool groups, and every agent should be able to reach for it /// agent should be able to reach for it — same reasoning as `set_status`.
/// regardless of which optional groups it's been granted — same
/// reasoning as `set_status`.
/// ///
/// `mark_todos_done` too (a critical bug every agent hit): it was /// `mark_todos_done` too: todos are pushed to an agent whether or not it
/// declared as a `#[tool]` fn but never added to *any* /// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/
/// group's [`tools`](Self::tools), including `Inbox`, so no agent could /// `remind`), so an agent without `Inbox` must still be able to clear
/// ever get it into `--allowedTools` and every call prompted for /// them.
/// 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.
pub const ALWAYS_ON_TOOLS: &'static [&'static str] = pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
&["set_status", "compact", "mark_todos_done"]; &["set_status", "compact", "mark_todos_done"];
/// The Claude built-in tool names enabled by this group. Only /// The Claude built-in tool names this permission enables. Only
/// `WebTools` returns a non-empty slice; all other groups return `&[]` /// `WebTools` returns a non-empty slice.
/// (they control MCP tools via `tools()` instead).
#[must_use] #[must_use]
pub fn builtin_tools(self) -> &'static [&'static str] { pub fn builtin_tools(self) -> &'static [&'static str] {
match self { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// The env-var spellings the meta renderer actually writes must round-trip /// The stored names are the `snake_case` spellings the old tool-group
/// back to groups. `strum::EnumString` and serde's `rename_all` are two /// and capability files used, so an imported grant decodes.
/// independent derives over the same names; this pins them together, since
/// the renderer writes what serde would produce and the parse is strum's.
#[test] #[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!( assert_eq!(
ToolGroup::parse_list("messaging, Meta ,web_tools"), names,
vec![ToolGroup::Messaging, ToolGroup::Meta, ToolGroup::WebTools] [
"messaging",
"meta",
"inbox",
"execution",
"web_tools",
"manage_root_agent"
]
); );
for group in ToolGroup::ALL { for perm in Permission::ALL {
let name: &'static str = (*group).into(); let json = serde_json::to_string(perm).unwrap();
assert_eq!(ToolGroup::parse_list(name), vec![*group], "{name}"); 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] #[test]
fn parse_list_falls_back_to_the_agent_default_rather_than_nothing() { fn only_the_root_agent_defaults_to_managing_the_others() {
for raw in ["", " ", ",,", "not_a_group,also_not"] { assert_eq!(
assert_eq!( Permission::default_for("atlas"),
ToolGroup::parse_list(raw), Permission::DEFAULT.to_vec()
ToolGroup::AGENT_DEFAULT.to_vec(), );
"{raw:?}" 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 /// Adding permissions only ever adds built-ins — the base set is in every
/// resolution, and no group can remove one. Both `--tools` callers (the /// resolution, and no permission can remove one. Both `--tools` callers (the
/// harness and the subagent daemon) rely on this to reason about "the /// harness and the subagent daemon) rely on this to reason about "the
/// parent's set" without re-deriving it. /// parent's set" without re-deriving it.
#[test] #[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!( assert_eq!(
builtin_tools_for(&[]), builtin_tools_for(&[]),
ALLOWED_BUILTIN_TOOLS.to_vec(), ALLOWED_BUILTIN_TOOLS.to_vec(),
"no groups resolves to exactly the base set" "no permissions resolves to exactly the base set"
); );
assert_eq!( assert_eq!(
builtin_tools_for(ToolGroup::AGENT_DEFAULT), builtin_tools_for(Permission::DEFAULT),
ALLOWED_BUILTIN_TOOLS.to_vec(), ALLOWED_BUILTIN_TOOLS.to_vec(),
"the default groups add no built-ins" "the default permissions add no built-ins"
); );
for group in ToolGroup::ALL { for perm in Permission::ALL {
let resolved = builtin_tools_for(&[*group]); let resolved = builtin_tools_for(&[*perm]);
for base in ALLOWED_BUILTIN_TOOLS { 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 /// 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 /// own precisely so it cannot get web egress from a parent without the
/// group. /// permission.
#[test] #[test]
fn web_egress_is_present_only_with_the_web_tools_group() { fn web_egress_is_present_only_with_the_web_tools_permission() {
let with = builtin_tools_for(&[ToolGroup::WebTools]); let with = builtin_tools_for(&[Permission::WebTools]);
assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch")); 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")); assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch"));
} }
/// No resolution may produce an empty `--tools` value. What an empty one /// 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 /// so this pins the rule that makes the question moot rather than any
/// answer to it. /// answer to it.
#[test] #[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!(!ALLOWED_BUILTIN_TOOLS.is_empty());
assert!(!builtin_tools_for(&[]).is_empty()); assert!(!builtin_tools_for(&[]).is_empty());
assert!(!builtin_tools_for(ToolGroup::ALL).is_empty()); assert!(!builtin_tools_for(Permission::ALL).is_empty());
assert!(!builtin_tools_arg().is_empty());
} }
/// Built-ins are not MCP tools and the two lists must not be mixed: a /// 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. /// does not filter the MCP surface at all.
#[test] #[test]
fn the_builtin_list_names_no_mcp_tools() { 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"); 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. /// inherit that through, so it gets Claude's built-in tool instead.
#[test] #[test]
fn subagent_gets_bash_when_parent_has_execution() { 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")); assert!(tools.contains(&"Bash"));
} }
@ -461,24 +310,27 @@ mod tests {
/// grants it unconditionally would go unnoticed. /// grants it unconditionally would go unnoticed.
#[test] #[test]
fn subagent_gets_no_bash_without_execution() { fn subagent_gets_no_bash_without_execution() {
for groups in [&[][..], &[ToolGroup::Messaging, ToolGroup::WebTools][..]] { for permissions in [&[][..], &[Permission::Messaging, Permission::WebTools][..]] {
let tools = subagent_builtin_tools_for(groups); let tools = subagent_builtin_tools_for(permissions);
assert!(!tools.contains(&"Bash"), "{groups:?} must not grant Bash"); assert!(
!tools.contains(&"Bash"),
"{permissions:?} must not grant Bash"
);
} }
} }
/// The parent (harness) resolver is untouched: `builtin_tools_for` / /// The parent (harness) resolver is untouched: `builtin_tools_for` —
/// `builtin_tools_arg` — what `hive-agent`'s own `--tools` and /// what `hive-agent`'s own `--tools` and
/// `--allowedTools` are built from — never produce `Bash`, with or /// `--allowedTools` are built from — never produce `Bash`, with or
/// without `Execution`. Only the subagent-specific resolver above adds /// without `Execution`. Only the subagent-specific resolver above adds
/// it; the operator's ruling was explicit that the main agent does not. /// it; the operator's ruling was explicit that the main agent does not.
#[test] #[test]
fn parent_resolver_never_gains_bash() { fn parent_resolver_never_gains_bash() {
for groups in [&[][..], &[ToolGroup::Execution][..], ToolGroup::ALL] { for permissions in [&[][..], &[Permission::Execution][..], Permission::ALL] {
let tools = builtin_tools_for(groups); let tools = builtin_tools_for(permissions);
assert!( assert!(
!tools.contains(&"Bash"), !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-agent-sock.workspace = true
hive-claude.workspace = true hive-claude.workspace = true
hive-runtime.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 # 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`. # parent's rather than a second list that drifts. See `session::build_config`.
# Also `term_msg`/`stream_enrich`, the rows `swarm_term` publishes. # Also `term_msg`/`stream_enrich`, the rows `swarm_term` publishes.
@ -30,7 +30,10 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
# `subagent_term`: the subject each subagent's rows are published on. # `subagent_term`: the subject each subagent's rows are published on.
# `agent-inference`: this agent's `subagent_models`, read with one direct get. # `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. # The agent's own queue credential, read under its store identity.
swarm-secret-client.workspace = true swarm-secret-client.workspace = true
tokio.workspace = true tokio.workspace = true

View file

@ -98,7 +98,7 @@ use hive_claude::{Attach, Claude, Config, SessionStore};
use hive_runtime::{ use hive_runtime::{
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop, 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 swarm_queue_client::agent_inference::AgentInference;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@ -552,12 +552,16 @@ impl State {
command command
} }
/// Read the swarm's key once, refuse a `model` outside the list in effect /// Read the swarm's keys once: refresh this agent's permissions (the
/// for this agent (see `check_model`), and return the subagent cap in /// tools the subagent about to run gets), refuse a `model` outside the list
/// effect (see `subagent_cap`). /// 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>> { async fn swarm_limits(&self, model: Option<&str>) -> anyhow::Result<Option<u32>> {
let read = match &self.models { let read = match &self.models {
Some(reader) => Some(reader.read().await), Some(reader) => {
reader.refresh_permissions().await;
Some(reader.read().await)
}
None => None, None => None,
}; };
let agent = self 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 /// Env var carrying the nix-configured `services.hyperhive.agent.availableModels` as a
/// comma-separated list, forwarded onto this daemon's unit by /// 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"; const AVAILABLE_MODELS_ENV: &str = "HIVE_AVAILABLE_MODELS";
/// The models this daemon may spawn a subagent on, from /// 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", "the swarm's subagent_models for this agent",
), ),
ModelList::Local(None) => return Ok(()), ModelList::Local(None) => return Ok(()),
// Same split/trim/skip-empty rules as `ToolGroup::parse_list`. // Split, trim, skip empty tokens.
ModelList::Local(Some(env)) => ( ModelList::Local(Some(env)) => (
env.split(',') env.split(',')
.map(str::trim) .map(str::trim)
@ -1186,7 +1190,8 @@ fn subagent_otel_attrs(name: &str) -> String {
/// gates covering one half each, neither substituting for the other: /// gates covering one half each, neither substituting for the other:
/// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly /// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly
/// what [`crate::mcp_config::build`] renders), `--tools` over **built-in** /// 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__*`, /// documents its one `Bash` exception. `--tools` does not filter `mcp__*`,
/// so the signal tools are unnamed in it (`docs/tools/subagent.md`). /// so the signal tools are unnamed in it (`docs/tools/subagent.md`).
/// ///
@ -1208,10 +1213,13 @@ fn build_config(
dir: Option<&str>, dir: Option<&str>,
signal_url: Option<&str>, signal_url: Option<&str>,
) -> Config { ) -> 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 // 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 // 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 // "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. // either way, and failing on it is safe under every reading.
assert!( assert!(
@ -2063,9 +2071,9 @@ fn acp_runtime(
sessions: &Path, sessions: &Path,
name: &str, name: &str,
) -> AcpRuntime<hive_claude::NeverCompact> { ) -> AcpRuntime<hive_claude::NeverCompact> {
let groups = hive_sh4re::permissions::effective_tool_groups(); let perms = crate::swarm_models::permissions_now();
let permit: PermissionPolicy = let permit: PermissionPolicy =
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &groups)); Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &perms));
AcpRuntime::new( AcpRuntime::new(
command, command,
acp_session_file(sessions, name), 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; /// - `other`-kind calls to tools of the MCP servers the session was handed;
/// - the file tools: `read`, `edit`, `search`; /// - the file tools: `read`, `edit`, `search`;
/// - `fetch` with the `web_tools` group; /// - `fetch` with the `web_tools` permission;
/// - `execute` with the `execution` group, as a claude subagent gets `Bash` /// - `execute` with the `execution` permission, as a claude subagent gets `Bash`
/// ([`hive_sh4re::permissions::subagent_builtin_tools_for`]). /// ([`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 { match ask.kind {
"other" => ask.mcp_server.is_some(), "other" => ask.mcp_server.is_some(),
"read" | "edit" | "search" => true, "read" | "edit" | "search" => true,
"fetch" => groups.contains(&ToolGroup::WebTools), "fetch" => perms.contains(&Permission::WebTools),
"execute" => groups.contains(&ToolGroup::Execution), "execute" => perms.contains(&Permission::Execution),
_ => false, _ => false,
} }
} }
@ -2984,15 +2992,13 @@ mod tests {
/// parent actually licenses rather than against a restatement of it that /// parent actually licenses rather than against a restatement of it that
/// could drift. /// could drift.
/// ///
/// ⚠️ **Scope: this catches a code divergence, not an environment one.** /// ⚠️ **Scope: this catches a code divergence, not a read one.** Both
/// Both sides resolve in one process off one `HIVE_TOOL_GROUPS`, so it /// sides resolve off one permission set here; that the parent and this
/// cannot see the two real units disagreeing about that var — which they /// daemon read the same key is a deployment property.
/// did until `nix/agent-modules/mcp.nix` forwarded it onto this daemon.
/// Nothing in a unit test reaches that; it is a deployment property.
#[test] #[test]
fn a_subagent_gets_no_builtin_its_parent_lacks() { fn a_subagent_gets_no_builtin_its_parent_lacks() {
let allowed = hive_sh4re::permissions::subagent_builtin_tools_for( 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); let config = build_config("n", None, None, None, None, None);
for tool in spawned_tools(&config).split(',') { for tool in spawned_tools(&config).split(',') {
@ -5412,8 +5418,8 @@ echo '{"type":"result","subtype":"success","is_error":false}'
kind, kind,
mcp_server: None, mcp_server: None,
}; };
let none: &[ToolGroup] = &[]; let none: &[Permission] = &[];
let both = &[ToolGroup::WebTools, ToolGroup::Execution]; let both = &[Permission::WebTools, Permission::Execution];
for kind in ["read", "edit", "search"] { for kind in ["read", "edit", "search"] {
assert!(acp_permits(&ask(kind), none), "{kind}"); 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 //! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list //! `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 //! a `start` or `continue` naming a `model` is checked against, in place of
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its //! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
//! `max_subagents`, when set, caps how many subagents run at once //! `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 //! **Read on every `start` and `continue`**, never cached, with the one subject the
//! agent's own queue credential is granted in that bucket //! 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::sync::{Arc, OnceLock};
use std::time::Duration; use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::{self, AgentInference, read_own}; use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
use swarm_queue_client::agent_permissions;
use crate::swarm_term::{Target, connect}; 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. /// How long after a failed connect the task tries again.
const RETRY_AFTER: Duration = Duration::from_mins(1); 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 { 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 (agent-service.nix).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info"; RUST_LOG = "info";
# This daemon resolves each subagent's `--tools` from the same # The harness's runtime selection, forwarded from its unit, so an ACP
# 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
# agent's subagents run on its ACP agent. All five are absent on a # agent's subagents run on its ACP agent. All five are absent on a
# claude agent, which the daemon reads as claude. # claude agent, which the daemon reads as claude.
HIVE_RUNTIME = config.systemd.services.hive-agent.environment.HIVE_RUNTIME or null; 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 # Its own key of the `agent-inference` KV bucket
# (`swarm_queue_client::agent_inference`), on the same terms. # (`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}"}" "--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}" "--store-cert-role ${lib.escapeShellArg authCertRole}"
]; ];
# The store identity arrives by `LoadCredential` and is named # 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 # 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) # wider subject added beside these (`$$JS.API.>`, another stream's name)
# would pass every presence check above. # 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 = ok =
let let
args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart; args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart;
@ -332,6 +332,7 @@ let
"'$$SWARM.term.{agent}.sub.>'" "'$$SWARM.term.{agent}.sub.>'"
"'$$JS.API.DIRECT.GET.KV_agent-schedules.$$KV.agent-schedules.{agent}'" "'$$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-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 # `forge/pull_diff.rs` diffs a PR's changed files itself, at a context width
# the forge's own `.diff` does not offer. # the forge's own `.diff` does not offer.
similar.workspace = true 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. # enum the hive enforces.
hive-sh4re.workspace = true hive-sh4re.workspace = true
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # 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. //! 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 //! Each agent's permissions are one value under its own key of
//! `agent-permissions` (`swarm_queue_client::agent_permissions`), which this //! `agent-permissions` (`swarm_queue_client::agent_permissions`), which this
//! daemon alone writes. The hive running the agent reads the key and restarts //! daemon alone writes. The agent's harness and subagent daemon apply the tool
//! the agent when its capabilities change. A field left unset leaves the agent //! permissions at their next turn or start; the hive running the agent
//! on the hive's compiled-in default; an empty list grants nothing. //! 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::collections::BTreeSet;
use std::sync::Arc; use std::sync::Arc;
@ -14,13 +15,13 @@ use axum::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use futures_util::StreamExt as _; use futures_util::StreamExt as _;
use hive_sh4re::permissions::Capability; use hive_sh4re::permissions::Permission;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply}; use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
use utoipa::ToSchema; 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::wanted::WantedWriter;
use crate::{AppState, error_problem, wanted_error_status}; 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; const MAX_ATTEMPTS: usize = 5;
/// One agent's permissions. A field left out is not set by the swarm, and the /// 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)] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Permissions { pub struct Permissions {
/// Capability names, `snake_case`: `manage_root_agent` mounts every agent's /// Permission names, `snake_case`: `messaging`, `meta`, `inbox`,
/// state and config into this one for recovery. /// `execution`, `web_tools`, `manage_root_agent`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Vec<String>>, example = json!(["manage_root_agent"]))] #[schema(value_type = Option<Vec<String>>, example = json!(["messaging", "meta", "inbox", "execution"]))]
capabilities: Option<Vec<Capability>>, permissions: Option<Vec<Permission>>,
} }
impl Permissions { impl Permissions {
/// The value to store, or why it is refused. /// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentPermissions, String> { fn into_stored(self) -> Result<AgentPermissions, String> {
if let Some(caps) = &self.capabilities { if let Some(perms) = &self.permissions {
let mut seen = std::collections::HashSet::new(); unique(perms)?;
for cap in caps {
if !seen.insert(*cap) {
let name: &str = (*cap).into();
return Err(format!("capability {name:?} is listed twice"));
}
}
} }
Ok(AgentPermissions { 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 { impl From<AgentPermissions> for Permissions {
fn from(v: AgentPermissions) -> Self { fn from(v: AgentPermissions) -> Self {
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. /// Set the permissions the swarm grants one agent.
/// ///
/// The value replaces the stored one whole. The hive running the agent reads /// 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 within 30 seconds and restarts the agent, after its current turn, when
/// it after its current turn. /// `manage_root_agent` changed; the agent applies the rest at its next turn.
#[utoipa::path( #[utoipa::path(
put, put,
path = "/api/agents/{name}/permissions", path = "/api/agents/{name}/permissions",
@ -126,8 +133,8 @@ pub(crate) async fn get_agent_permissions(
responses( responses(
(status = 200, description = "the permissions as stored", body = Permissions), (status = 200, description = "the permissions as stored", body = Permissions),
(status = 400, description = "not an identifier, not declared on any hive, or a \ (status = 400, description = "not an identifier, not declared on any hive, or a \
capability listed twice (problem+json)", body = String), permission listed twice (problem+json)", body = String),
(status = 422, description = "an unknown field or capability name"), (status = 422, description = "an unknown field or permission name"),
(status = 503, description = "no swarm queue is configured on this host, or it is \ (status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String), not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be written (problem+json)", (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) 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 /// 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 /// 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. /// 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. /// for the agents in `declared` only.
async fn import_into( async fn import_into(
store: &async_nats::jetstream::kv::Store, store: &async_nats::jetstream::kv::Store,
@ -284,13 +386,13 @@ async fn import_into(
import: Import, import: Import,
) -> anyhow::Result<ImportReply> { ) -> anyhow::Result<ImportReply> {
let (mut written, mut kept, mut skipped) = (Vec::new(), Vec::new(), Vec::new()); 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) { if !declared.contains(&agent) {
skipped.push(agent); skipped.push(agent);
continue; continue;
} }
let value = AgentPermissions { let value = AgentPermissions {
capabilities: Some(caps), permissions: Some(caps),
}; };
if write(store, &agent, &value, None).await? { if write(store, &agent, &value, None).await? {
written.push(agent); written.push(agent);
@ -314,7 +416,7 @@ mod tests {
use crate::test_nats::TestNats; use crate::test_nats::TestNats;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use hive_sh4re::permissions::Capability; use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{Import, ImportReply}; use swarm_queue_client::agent_permissions::{Import, ImportReply};
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> { async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
@ -330,7 +432,7 @@ mod tests {
fn granted() -> Permissions { fn granted() -> Permissions {
Permissions { Permissions {
capabilities: Some(vec![Capability::ManageRootAgent]), permissions: Some(vec![Permission::ManageRootAgent]),
} }
} }
@ -368,17 +470,17 @@ mod tests {
); );
assert_eq!( assert_eq!(
published(&client, "atlas").await.as_deref(), 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()); assert_eq!(get(&state, "atlas").await, granted());
let none = Permissions { let none = Permissions {
capabilities: Some(Vec::new()), permissions: Some(Vec::new()),
}; };
let _ = put(&state, "atlas", none.clone()).await.expect("set"); let _ = put(&state, "atlas", none.clone()).await.expect("set");
assert_eq!( assert_eq!(
published(&client, "atlas").await.as_deref(), published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":[]}"#) Some(r#"{"permissions":[]}"#)
); );
assert_eq!( assert_eq!(
@ -418,11 +520,11 @@ mod tests {
.status() .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. /// a duplicate is a 400, and none of them changes what is stored.
#[tokio::test] #[tokio::test]
async fn a_bad_body_is_refused_and_the_grant_stays() { 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 nats = TestNats::start();
let client = nats.client().await; let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await; let state = crate::agent_limits::tests::declared(&client).await;
@ -431,16 +533,20 @@ mod tests {
for (body, status) in [ for (body, status) in [
( (
r#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#, r#"{"permissions":["manage_root_agent"],"tool_groups":["meta"]}"#,
StatusCode::UNPROCESSABLE_ENTITY, 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, 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, StatusCode::BAD_REQUEST,
), ),
] { ] {
@ -466,15 +572,15 @@ mod tests {
.await .await
.expect("bucket"); .expect("bucket");
store store
.put("atlas", br#"{"capabilities":[]}"#.to_vec().into()) .put("atlas", br#"{"permissions":[]}"#.to_vec().into())
.await .await
.expect("seed"); .expect("seed");
let declared = ["atlas".to_owned(), "ruth".to_owned()].into(); let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
let import = Import { let import = Import {
capabilities: [ permissions: [
("atlas".to_owned(), vec![Capability::ManageRootAgent]), ("atlas".to_owned(), vec![Permission::ManageRootAgent]),
("ruth".to_owned(), vec![Capability::ManageRootAgent]), ("ruth".to_owned(), vec![Permission::ManageRootAgent]),
("iris".to_owned(), vec![Capability::ManageRootAgent]), ("iris".to_owned(), vec![Permission::ManageRootAgent]),
] ]
.into(), .into(),
}; };
@ -492,12 +598,76 @@ mod tests {
); );
assert_eq!( assert_eq!(
published(&client, "atlas").await.as_deref(), published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":[]}"#) Some(r#"{"permissions":[]}"#)
); );
assert_eq!( assert_eq!(
published(&client, "ruth").await.as_deref(), 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); 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`. /// /api/agents/{name}/inference` takes them. Absent as `limits`.
#[serde(default)] #[serde(default)]
inference: Option<inference::InferenceSettings>, 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 /// Where the queued job landed — a caller polls `/api/jobq/graph` (or
@ -1795,10 +1800,10 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest, request_body = CreateAgentRequest,
responses( responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse), (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 = 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 = 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` or `inference` is set and cannot be stored (problem+json)", body = String), (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), (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" tag = "agents"
@ -1813,15 +1818,7 @@ async fn create_agent(
// Settings are checked before anything is created, so a bad value stops // Settings are checked before anything is created, so a bad value stops
// the request rather than leaving an agent without them. // the request rather than leaving an agent without them.
let limits = req let settings = CreateSettings::parse(req.limits, req.inference, req.permissions)
.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()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?; .map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
// Two checks, and the second is the one that makes the field worth // 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 // Checked after the roster, so a mistyped hive stays a 400 while the queue
// is down. // 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)?) Some(schedules::queue(&state).map_err(schedules::refused)?)
} else { } else {
None None
@ -1916,7 +1913,14 @@ async fn create_agent(
// settings behind for the name. // settings behind for the name.
let stored = match &settings_queue { let stored = match &settings_queue {
Some(client) => { 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(), 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 /// What `create_agent` replaced in the settings stores, to put back if the
/// creation is refused after the write. /// creation is refused after the write.
#[derive(Default)] #[derive(Default)]
struct StoredSettings { struct StoredSettings {
limits: agent_limits::Replaced, limits: agent_limits::Replaced,
inference: inference::Replaced, inference: inference::Replaced,
permissions: agent_permissions::Replaced,
} }
impl StoredSettings { impl StoredSettings {
/// Store whichever of `limits` and `inference` are given. A failure on /// Store whichever of `limits`, `permissions` and `inference` are given, in
/// the second puts back the first. /// that order. A failure on one puts back the ones stored before it.
async fn store( async fn store(
client: &async_nats::Client, client: &async_nats::Client,
agent: &str, agent: &str,
limits: Option<&swarm_queue_client::agent_limits::AgentCaps>, limits: Option<&swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<&swarm_queue_client::agent_inference::AgentInference>, inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
) -> Result<Self, problem_details::ProblemDetails> { ) -> Result<Self, problem_details::ProblemDetails> {
let mut stored = Self::default(); let mut stored = Self::default();
if let Some(limits) = limits { if let Some(limits) = limits {
stored.limits = agent_limits::store(client, agent, limits).await?; 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 { if let Some(inference) = inference {
match inference::store(client, agent, inference).await { match inference::store(client, agent, inference).await {
Ok(replaced) => stored.inference = replaced, Ok(replaced) => stored.inference = replaced,
@ -1984,6 +2028,7 @@ impl StoredSettings {
async fn roll_back(self, client: &async_nats::Client, agent: &str) { async fn roll_back(self, client: &async_nats::Client, agent: &str) {
agent_limits::restore(client, agent, self.limits).await; agent_limits::restore(client, agent, self.limits).await;
inference::restore(client, agent, self.inference).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::put_agent_permissions,
agent_permissions::delete_agent_permissions agent_permissions::delete_agent_permissions
)) ))
.routes(routes!(agent_permissions::get_permission_catalogue))
.routes(routes!(get_config_prs)) .routes(routes!(get_config_prs))
.routes(routes!(create_agent)) .routes(routes!(create_agent))
.routes(routes!(mint_agent_forge_token)) .routes(routes!(mint_agent_forge_token))
@ -3771,6 +3817,7 @@ mod tests {
hive: "pr1maa".to_owned(), hive: "pr1maa".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -3811,6 +3858,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -3973,6 +4021,7 @@ mod tests {
agent, agent,
Some(&caps(r#"{"cpu_quota":"400%"}"#)), Some(&caps(r#"{"cpu_quota":"400%"}"#)),
Some(&inference), Some(&inference),
None,
) )
.await; .await;
assert!(stored.is_err(), "{agent}: the inference write must fail"); 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, /// A valid cap beside an invalid model stores neither and queues nothing,
/// on a server that could have stored the cap. /// on a server that could have stored the cap.
#[tokio::test] #[tokio::test]
@ -4044,6 +4147,7 @@ mod tests {
hive: hive.to_owned(), hive: hive.to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5166,6 +5270,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5219,6 +5324,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5268,6 +5374,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5325,6 +5432,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5529,6 +5637,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await

View file

@ -564,7 +564,7 @@ impl Policy {
"$JS.API.DIRECT.GET.{}.>", "$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_limits::STREAM 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 // (`swarm_queue_client::agent_permissions`), read the same way and
// every key for the same reason. // every key for the same reason.
subjects.push(format!( subjects.push(format!(
@ -852,15 +852,15 @@ mod tests {
} }
/// A hive reads any agent's permissions and can change none of them; an /// 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 /// agent, with the template `swarm-nats.nix` passes, reads its own key and
/// none of them. /// no other, and writes none.
#[test] #[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}; use swarm_queue_client::agent_permissions::{STREAM, direct_get_subject, key_subject};
let p = policy() let p = policy()
.with_agent_token_subjects(vec![ .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"); .expect("a per-agent template is valid");
let hive = p.permissions("hive-alpha").expect("a hive is admitted"); 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 = p.agent_token_permissions("atlas").expect("configured");
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject)); let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
assert!( assert!(agent_may(&direct_get_subject("atlas")));
!agent_may(&direct_get_subject("atlas")), for forbidden in [direct_get_subject("ruth"), key_subject("atlas")] {
"an agent may read its own permissions: {:?}", assert!(!agent_may(&forbidden), "atlas may publish {forbidden}");
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")
));
} }
/// A hive sends its import on its own subject only; the reader hears every /// 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 # `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`. # runs the same way. Explicit for the same reason as `notices`.
agent-limits = ["async-nats/jetstream"] agent-limits = ["async-nats/jetstream"]
# `agent_permissions::AgentPermissions` and `read`, for hive-c0re and # `agent_permissions::AgentPermissions` and `read`, for hive-c0re, the agent
# swarm-controller. Pulls `hive-sh4re` for the `Capability` names, so the # harness, the subagent daemon and swarm-controller. Pulls `hive-sh4re` for the
# crates that only need the bucket name do not link it. # `Permission` names, so the crates that only need the bucket name do not link
# it.
agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"] agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"]
[dependencies] [dependencies]

View file

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

View file

@ -291,12 +291,13 @@ enum AgentVerb {
#[command(subcommand)] #[command(subcommand)]
command: InferenceVerb, 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 /// The agent applies its tool permissions at its next turn; the hive
/// change, restarts the agent after its current turn. Unset leaves the /// running it restarts it, after its current turn, when
/// agent on its hive's default: none, except `ruth`, who gets /// `manage_root_agent` changes. Unset leaves the agent on the default:
/// `manage_root_agent`. /// `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent`
/// for `ruth`.
Permissions { Permissions {
#[command(subcommand)] #[command(subcommand)]
command: PermissionsVerb, command: PermissionsVerb,
@ -305,11 +306,11 @@ enum AgentVerb {
#[derive(Subcommand)] #[derive(Subcommand)]
enum PermissionsVerb { 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), Get(InferenceAgentArgs),
/// Replace the capabilities the swarm grants an agent. /// Replace the permissions the swarm grants an agent.
Set(PermissionsSetArgs), 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), Clear(InferenceAgentArgs),
} }
@ -317,15 +318,16 @@ enum PermissionsVerb {
struct PermissionsSetArgs { struct PermissionsSetArgs {
/// Agent name. A hive must declare it. /// Agent name. A hive must declare it.
agent: String, agent: String,
/// The capabilities to grant, comma-separated (`manage_root_agent`). /// The permissions to grant, comma-separated: any of `messaging`,
/// `--capabilities ''` grants none. /// `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`.
/// `--permissions ''` grants none.
#[arg( #[arg(
long, long,
value_name = "CAPABILITIES", value_name = "PERMISSIONS",
value_delimiter = ',', value_delimiter = ',',
required = true required = true
)] )]
capabilities: Vec<String>, permissions: Vec<String>,
#[command(flatten)] #[command(flatten)]
socket: ControllerSocketArg, socket: ControllerSocketArg,
} }
@ -716,7 +718,7 @@ fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
match command { match command {
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent), PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
PermissionsVerb::Set(a) => { 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), 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. //! agent, through swarm-controller's `/api/agents/{name}/permissions` routes.
//! //!
//! The hive running the agent reads its key every 30 seconds and restarts the //! The agent applies its tool permissions at its next turn; the hive running
//! agent, after its current turn, when its capabilities change. Unset leaves //! it restarts it, after its current turn, when `manage_root_agent` changes.
//! the agent on its hive's default; an empty list grants nothing. //! Unset leaves the agent on the default set; an empty list grants nothing.
//! //!
//! The shape below mirrors the controller's `Permissions`, for the reason //! 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. //! controller, which answers 422 for one it does not know.
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@ -19,7 +19,7 @@ use crate::remote::Target;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Permissions { struct Permissions {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
capabilities: Option<Vec<String>>, permissions: Option<Vec<String>>,
} }
fn runtime() -> Result<tokio::runtime::Runtime> { fn runtime() -> Result<tokio::runtime::Runtime> {
@ -50,26 +50,26 @@ pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
Ok(()) 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. /// 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 uri = member(agent)?;
let stored: Permissions = runtime()?.block_on(send( let stored: Permissions = runtime()?.block_on(send(
target, target,
hyper::Method::PUT, hyper::Method::PUT,
&uri, &uri,
Some(&with_capabilities(capabilities)), Some(&with_permissions(permissions)),
"permissions-set", "permissions-set",
))?; ))?;
print!("{}", render(&stored)); print!("{}", render(&stored));
Ok(()) 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. /// gives, is dropped, so that spelling grants nothing.
fn with_capabilities(capabilities: Vec<String>) -> Permissions { fn with_permissions(permissions: Vec<String>) -> Permissions {
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, None,
"permissions-clear", "permissions-clear",
))?; ))?;
println!("cleared; {agent} goes back to its hive's default"); println!("cleared; {agent} goes back to the default");
Ok(()) Ok(())
} }
fn render(p: &Permissions) -> String { fn render(p: &Permissions) -> String {
let caps = match p.capabilities.as_deref() { let caps = match p.permissions.as_deref() {
None => "(not set; the hive's default)".to_owned(), None => "(not set; the default)".to_owned(),
Some([]) => "(none)".to_owned(), Some([]) => "(none)".to_owned(),
Some(caps) => caps.join(", "), Some(caps) => caps.join(", "),
}; };
format!("capabilities: {caps}\n") format!("permissions: {caps}\n")
} }
#[cfg(test)] #[cfg(test)]
@ -103,24 +103,21 @@ mod tests {
#[test] #[test]
fn get_tells_unset_from_none() { fn get_tells_unset_from_none() {
let unset: Permissions = serde_json::from_str("{}").unwrap(); let unset: Permissions = serde_json::from_str("{}").unwrap();
assert_eq!( assert_eq!(render(&unset), "permissions: (not set; the default)\n");
render(&unset), let none: Permissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
"capabilities: (not set; the hive's default)\n" assert_eq!(render(&none), "permissions: (none)\n");
);
let none: Permissions = serde_json::from_str(r#"{"capabilities":[]}"#).unwrap();
assert_eq!(render(&none), "capabilities: (none)\n");
let ruth: Permissions = let ruth: Permissions =
serde_json::from_str(r#"{"capabilities":["manage_root_agent"]}"#).unwrap(); serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
assert_eq!(render(&ruth), "capabilities: manage_root_agent\n"); 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] #[test]
fn an_empty_list_flag_grants_nothing() { fn an_empty_list_flag_grants_nothing() {
let set = with_capabilities(vec![String::new()]); let set = with_permissions(vec![String::new()]);
assert_eq!( assert_eq!(
serde_json::to_string(&set).unwrap(), serde_json::to_string(&set).unwrap(),
r#"{"capabilities":[]}"# r#"{"permissions":[]}"#
); );
} }
} }