Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
83 changed files with 3438 additions and 3419 deletions

3
Cargo.lock generated
View file

@ -2049,6 +2049,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"strum", "strum",
"tracing",
] ]
[[package]] [[package]]
@ -4883,7 +4884,6 @@ dependencies = [
"hive-jobq-metrics", "hive-jobq-metrics",
"hive-jobq-wire", "hive-jobq-wire",
"hive-log", "hive-log",
"hive-sh4re",
"hive-types", "hive-types",
"hmac 0.13.0", "hmac 0.13.0",
"http", "http",
@ -5014,7 +5014,6 @@ dependencies = [
"axum", "axum",
"data-encoding", "data-encoding",
"futures-util", "futures-util",
"hive-sh4re",
"reqwest", "reqwest",
"rustls", "rustls",
"serde", "serde",

View file

@ -39,7 +39,7 @@ declarations.
## Trust boundary & security ## Trust boundary & security
- **What's the operator/agent trust boundary? What's a permission?** → - **What's the operator/agent trust boundary? What's a capability?** →
[`trust-boundary/boundary.md`](trust-boundary/boundary.md). [`trust-boundary/boundary.md`](trust-boundary/boundary.md).
- **Agent trust model, prompt-injection threat model, credential - **Agent trust model, prompt-injection threat model, credential
isolation?** → [`trust-boundary/security.md`](trust-boundary/security.md). isolation?** → [`trust-boundary/security.md`](trust-boundary/security.md).

View file

@ -2,8 +2,8 @@
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
Agents are a **flat set**: the `manage_root_agent` permission scopes Agents are a **flat set**: the capability store scopes which agents can
which agents can manage which others. manage which others.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
@ -36,13 +36,13 @@ One entry per agent _this hive_ has state/config for, in name order.
Unlike the swarm roster, it's a derived cache: the reconcile pass below Unlike the swarm roster, it's a derived cache: the reconcile pass below
rebuilds it from what the hive observes locally (config repos cloned, containers spawned), and it rebuilds it from what the hive observes locally (config repos cloned, containers spawned), and it
answers a narrower question than "does this agent exist": _which of answers a narrower question than "does this agent exist": _which of
this hive's local agents the `ManageRootAgent` permission's bind-mounts this hive's local agents the `ManageRootAgent` capability's bind-mounts
should cover._ `topology::all_agents` is the only reader that matters. should cover._ `topology::all_agents` is the only reader that matters.
That reader is a permission boundary: the set it returns is what an agent That reader is a permission boundary: the set it returns is what an agent
holding the `ManageRootAgent` permission gets bind-mounted (each other holding the `ManageRootAgent` capability gets bind-mounted (each other
agent's `state` read-write and `config` read-only; never `harness`). An agent's `state` read-write and `config` read-only; never `harness`). An
agent holding no permission sees its own dirs and nothing else: agent holding no capability sees its own dirs and nothing else:
`ManageRootAgent` is the only grant that reaches another agent's state `ManageRootAgent` is the only grant that reaches another agent's state
dir. See dir. See
[`persistence.md`](persistence.md)'s _Cross-agent access to state._ [`persistence.md`](persistence.md)'s _Cross-agent access to state._
@ -51,7 +51,7 @@ dir. See
`topology.json` may also be a map of `name → parent | null`; the reader `topology.json` may also be a map of `name → parent | null`; the reader
accepts that shape and takes its keys as the roster. An empty accepts that shape and takes its keys as the roster. An empty
roster costs more than a cosmetic gap: every permission holder loses its roster costs more than a cosmetic gap: every capability holder loses its
mounts until the next reconcile pass writes the array form. mounts until the next reconcile pass writes the array form.
### Why meta, not per-agent `agent.nix` ### Why meta, not per-agent `agent.nix`
@ -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 permissions; its prompt, tool its socket flavour and its default capability grant; 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 `manage_root_agent` permission: ruth deploy log). That grant is the `ManageRootAgent` capability: 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,10 +118,11 @@ 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 the agent's - **Tool allow-list** — the MCP tools claude sees come from
permissions alone, the same mechanism for every agent `HIVE_TOOL_GROUPS` alone, the same mechanism for every agent
([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's default ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's wider default
permissions add only `manage_root_agent` (`Permission::default_for`). surface is a wider default grant (`ToolGroup::MANAGER_DEFAULT`, seeded
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
permission sees no other agent's config at all. capability 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,31 @@ 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` permission (granted per agent in Agents holding the `manage_root_agent` capability (granted per agent in
the swarm's `agent-permissions` record; see `capabilities.json`; see `hive-sh4re/src/permissions.rs`) get additional
`docs/process/conventions.md#permissions`) get additional host-side bind 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 permission means "may proposed repo, not just direct children. The capability 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 permission only has its direct children's config An agent without the capability 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 granting or
an agent, after its current turn, when its `manage_root_agent` changes. revoking this capability doesn't change any mount until that agent's
container is rebuilt/restarted.
The root agent holds the permission by default: an agent whose record The root agent gets the capability by default, seeded on its autodeploy
leaves `permissions` unset gets the default, which includes path (`workers::auto_update::ensure_root_agent`) so the recovery mounts
`manage_root_agent` for `ruth` and for no other agent. Setting her are there from its first container. That seed only fires while
permissions without it revokes it. `capabilities.json` doesn't exist yet: any grant or revoke through
the dashboard creates the file, so a revoked root-agent grant stays
revoked and isn't re-applied on the next hive-c0re restart.
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 +351,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 the agent's permissions, varies is which MCP tools it surfaces (gated by tool groups and
which the swarm sets). No `role:manager` block renders only capabilities in `agent.nix`). 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 `manage_root_agent` permission gets every other An agent holding the `ManageRootAgent` capability 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 without that permission This is the **only** cross-agent mount. An agent holding no capability
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 permission, nothing else. unless it holds the capability, 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,9 +384,17 @@ 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 `manage_root_agent` `topology::all_agents`, which is the set the `ManageRootAgent`
permission grants mounts over. The reader also accepts a map-shaped capability 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.
- `capabilities.json` — per-agent capability grants
(`{ "ruth": ["manage_root_agent"] }`). Written by
`capabilities::set_caps`; injected as `HIVE_CAPABILITIES` env
var. Absent agents have no extra capabilities.
- `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" } }`).
@ -410,15 +418,6 @@ 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`
holds the permissions the swarm grants this hive's agents, as
`workers::swarm_permissions` last read them from the swarm's
`agent-permissions` bucket (every 30 seconds): a copy, not the
record, which `swarmctl agent permissions` sets. `set_nspawn_flags`
reads it at every container start for `manage_root_agent`. Each agent gets
its own entry from it as `swarm-permissions.json` in its runtime dir, which
it holds until its own first read of its key succeeds.
hive-c0re renders containers onto `meta#<n>` at creation. hive-c0re renders containers onto `meta#<n>` at creation.
## Destroy vs purge ## Destroy vs purge

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 permissions). `Reminder` rows always carry `owner == self`. default capabilities). `Reminder` rows always carry `owner == self`.
Per-variant fields: Per-variant fields:
@ -272,83 +272,97 @@ 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.
## Permissions ## Tool groups
One set of named `Permission` values (`hive_sh4re::permissions::Permission`) A set of named `ToolGroup` values (`hive_sh4re::permissions::ToolGroup`)
decides what an agent may do: which MCP and built-in tools it gets, and which determines the MCP tool surface an agent receives, not a hardcoded
host access it has. binary flavor.
| Permission | Grants | | Group | Tools |
|---|---| |---|---|
| `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` | 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`. | | `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`. |
| `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` | | `lifecycle` | none |
| `manage_root_agent` | every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus `/applied` and `/meta` (ro) | | `forge` | none |
| `web_tools` | none (gates the Claude built-ins `WebFetch`/`WebSearch`, not an MCP tool) |
**Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`, **Always-on tools** — `ToolGroup::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of its `compact`, and `mark_todos_done` to every agent regardless of which
permissions. The operator dashboard depends on every agent being able to groups it holds. The operator dashboard depends on every agent
report its status chip, and the server-side `SetStatus` handler has no being able to report its status chip, and the server-side `SetStatus` handler
permission check (only length validation), so gating it would only desync has no tool-group check (only length validation), so gating it would only
the `--allowedTools` list from what the host actually accepts. Revoking desync the `--allowedTools` list from what the host actually accepts.
`meta` therefore drops `get_agent_meta` but never `set_status`. Revoking `meta` therefore drops `get_agent_meta` but never `set_status`.
`mark_todos_done` is here because producers push todos to an agent whether `mark_todos_done` is here because producers push todos to an agent independent of
or not it holds `inbox`, and an agent without it still needs to clear them. whether it holds `inbox` — an agent without that group still needs a way to
clear them.
**Where they live** — in the swarm, not on the hive: the `permissions` field of **Config storage** — per-agent tool groups live in
the agent's key in the `agent-permissions` KV bucket `/var/lib/hyperhive/meta/tool-groups.json` (hive-c0re-owned, committed to the
(`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone meta repo alongside `topology.json`). Format: `{ "alice": ["messaging", "meta",
writes. Set them with `swarmctl agent permissions get|set|clear <agent>` "inbox", "lifecycle"], "bob": ["messaging", "meta", "inbox"] }`. An absent entry
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen means "use role default." Tool permissions are intentionally NOT configurable
(`permissions` in `POST /api/agents`). A missing key or an unset field means from `agent.nix` — that file changes through config PRs the manager opens, so
the default (`Permission::default_for`): `messaging`, `meta`, `inbox`, letting it declare its own groups would let the manager grant itself any tool by
`execution`, plus `manage_root_agent` for `ruth`. `"permissions": []` grants submitting a config commit, bypassing the operator gate.
none.
**How a change applies** — **Setting groups** — the operator sets groups via the dashboard or
- The agent's harness reads its own key every 10 seconds and builds every `hive-c0re::tool_groups::set_groups(name, groups)`. After a change
turn's `--tools`, `--allowedTools` and MCP config from the last value, so a `meta::sync_agents` commits the updated file; the next agent rebuild picks up
change reaches the next turn. An ACP agent's permission check reads it on the new `HIVE_TOOL_GROUPS` env var. Agents with no entry get no var.
each request.
- The subagent daemon reads the key on each `start`/`continue`; a running
subagent keeps its tools.
- hive-c0re reads each of its agents' keys every 30 seconds
(`workers/swarm_permissions.rs`) into
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo.
nspawn fixes the bind mounts at container start, so when an agent's
`manage_root_agent` changes hive-c0re restarts it after its current turn.
- hive-c0re copies each agent's entry from that file into the agent's
runtime dir (`/run/hive/swarm-permissions.json` in the container) at every
start and after every 30-second pass, with whether the import below is
still pending. The harness and the subagent daemon hold that copy until
their own first read succeeds, so an agent whose queue is down starts on
its last stored grant, not the default.
Every reader keeps its last good value on a failed read. **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.
**Moving off the meta repo** — on its first boot with this code a hive copies **Updating the surface** — when you add a new `#[tool]` fn to `AgentServer`
`meta/capabilities.json` and `meta/tool-groups.json` into the stored value, in `hive-agent-mcp/src/mcp/mod.rs`, add its name to the matching `ToolGroup::tools()`
sends them to swarm-controller once as one list per agent (without slice in `hive-sh4re/src/permissions.rs`. That's the single source of truth;
`lifecycle` and `forge`, which gave no tools; the controller writes a key `mcp_config::allowed_mcp_tools` (in `hive-agent/src/mcp_config.rs`) reads it at
only where there is none, and only for agents that hive's wanted state session start.
declares), and deletes
both files from the meta repo when the controller answers
(`workers/permissions_import.rs`). Until then hive-c0re reads no keys, and an
agent that reads its own key unset keeps the grant in its copy.
**Not configurable from `agent.nix`** — that file changes through config PRs ## Capabilities
an agent can open, so letting it declare its own permissions would let an
agent grant itself any tool by submitting a config commit, bypassing the
operator gate.
**Adding to the set** — add a variant to `Permission` in Capabilities gate system-level access that goes beyond the MCP tool surface —
`hive-sh4re/src/permissions.rs` and to `Permission::ALL`, and enforce it where things an agent can *access*, not just *call*. Parallel to tool groups but
it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`) orthogonal: an agent can have a tool group that registers a tool AND a capability
goes into the matching `Permission::tools()` slice: that's the single source that allows the underlying resource access.
of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`)
reads each turn. | 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 on the holder's next container rebuild/restart |
**Config storage** — per-agent capabilities live in
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`.
Format: `{ "ruth": ["manage_root_agent"] }`.
An absent entry means "no extra capabilities." `render_flake` in `meta.rs`
reads this file and injects `HIVE_CAPABILITIES` (comma-separated
`snake_case` names) into each agent's systemd service env; absent entries emit
no env var so agents without capabilities don't trigger a spurious rebuild.
**Setting capabilities** — the operator sets capabilities via the
C4P4B1L1T13S section in the dashboard's P3RM1SS10NS tab.
`hive-c0re::capabilities::set_caps(name, caps)` is the write path.
After a change `meta::sync_agents` commits the updated file; the next agent
rebuild picks up the new `HIVE_CAPABILITIES` env var.
**Runtime resolution** — at session start the harness reads `HIVE_CAPABILITIES`
and resolves each token to a `Capability` variant, logging and skipping
unrecognised ones. An absent or empty var means no extra capabilities.
**Capability NOT configurable from `agent.nix`** — same reasoning as tool
groups: an agent that could grant its own capabilities via a config commit would
bypass the operator approval gate.
**Adding a new capability** — add a variant to `Capability` in
`hive-sh4re/src/permissions.rs` + an arm to `as_str`. Add it to `Capability::ALL`
(the source of truth for the permissions UI columns). Implement the access
check in the relevant handler (`hive-c0re/src/socket_server/mod.rs`,
`coordinator.rs`, or a handler under `hive-c0re/src/dashboard/`).
## Async forms ## Async forms

View file

@ -71,8 +71,9 @@ 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 — 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/perm-change — 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` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
| `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 |
@ -89,7 +90,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` and `Provision`'s agent registration. Two meta `MetaLock`, `WritePermFile` 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
@ -138,6 +139,7 @@ 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)
@ -213,7 +215,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` — they `Prebuild`, `Provision`, `MetaLock`, `WritePermFile` — 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 (with their permissions), link forge, matrix and GitHub accounts | | `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents, 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,12 +7,11 @@ 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`** permission: `extra_server_required_permission` in holds the **`execution`** tool group: `extra_server_required_group` in
`hive-agent/src/mcp_config.rs` suppresses it entirely without that `hive-agent/src/mcp_config.rs` suppresses it entirely without that
permission, so an agent lacking `execution` gets no `mcp__bash__*` tools at group, so an agent lacking `execution` gets no `mcp__bash__*` tools at
all, not just an unused grant. The harness rewrites the config each turn, all, not just an unused grant. See "Relationship to the `execution` tool
so a change applies at the next one. See "Relationship to the group" below.
`execution` permission" below.
## Tools ## Tools
@ -123,14 +122,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` permission ## Relationship to the `execution` tool group
`Permission::Execution` (in `Permission::DEFAULT`) is the real gate on bash: `ToolGroup::Execution` (in `AGENT_DEFAULT`) is the real gate on bash:
`extra_server_required_permission` (in `hive-agent/src/mcp_config.rs`) `extra_server_required_group` (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_permission` gates, not on the server that `extra_server_required_group` gates, not on the
`mcp__hyperhive__*` allowlist `tools()` builds. Removing `execution` `mcp__hyperhive__*` allowlist `tools()` builds. Removing `execution`
from an agent's permissions **does** remove bash availability. from an agent's groups **does** remove bash availability.

View file

@ -399,24 +399,23 @@ 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_for`, wrapping the (`hive_sh4re::permissions::subagent_builtin_tools_arg`, wrapping the
harness's own `builtin_tools_for`) over the agent's permissions, which the harness's own `builtin_tools_for`) from the same `HIVE_TOOL_GROUPS`:
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 `web_tools` and not otherwise. A running `WebSearch` for an agent granted the `web_tools` tool group and not
subagent keeps the tools it started with. See otherwise. 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` permission** — the permission that already grants the holds the `execution` tool group** — the group 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 permissions it holds, only a subagent spawned by an `Bash` no matter which groups 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

@ -12,10 +12,6 @@ This document contains the help content for the `swarmctl` command-line program.
* [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get) * [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get)
* [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set) * [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set)
* [`swarmctl agent inference clear`↴](#swarmctl-agent-inference-clear) * [`swarmctl agent inference clear`↴](#swarmctl-agent-inference-clear)
* [`swarmctl agent permissions`↴](#swarmctl-agent-permissions)
* [`swarmctl agent permissions get`↴](#swarmctl-agent-permissions-get)
* [`swarmctl agent permissions set`↴](#swarmctl-agent-permissions-set)
* [`swarmctl agent permissions clear`↴](#swarmctl-agent-permissions-clear)
* [`swarmctl schedule`↴](#swarmctl-schedule) * [`swarmctl schedule`↴](#swarmctl-schedule)
* [`swarmctl schedule add`↴](#swarmctl-schedule-add) * [`swarmctl schedule add`↴](#swarmctl-schedule-add)
* [`swarmctl schedule list`↴](#swarmctl-schedule-list) * [`swarmctl schedule list`↴](#swarmctl-schedule-list)
@ -70,7 +66,6 @@ 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 permissions the swarm grants an agent
@ -203,77 +198,6 @@ Clear every field for an agent, so it goes back to its own values
## `swarmctl agent permissions`
Show or change the permissions the swarm grants an agent.
The agent applies its tool permissions at its next turn; the hive running it restarts it, after its current turn, when `manage_root_agent` changes. Unset leaves the agent on the default: `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent` for `ruth`.
**Usage:** `swarmctl agent permissions <COMMAND>`
###### **Subcommands:**
* `get` — Show the permissions the swarm grants an agent, or that it sets none
* `set` — Replace the permissions the swarm grants an agent
* `clear` — Clear the agent's permissions, so the default applies again
## `swarmctl agent permissions get`
Show the permissions the swarm grants an agent, or that it sets none
**Usage:** `swarmctl agent permissions get [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent name
###### **Options:**
* `--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`.
## `swarmctl agent permissions set`
Replace the permissions the swarm grants an agent
**Usage:** `swarmctl agent permissions set [OPTIONS] --permissions <PERMISSIONS> <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent name. A hive must declare it
###### **Options:**
* `--permissions <PERMISSIONS>` — The permissions to grant, comma-separated: any of `messaging`, `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`. `--permissions ''` grants none
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl agent permissions clear`
Clear the agent's permissions, so the default applies again
**Usage:** `swarmctl agent permissions clear [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent name
###### **Options:**
* `--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`.
## `swarmctl schedule` ## `swarmctl schedule`
Manage scheduled prompts. Manage scheduled prompts.

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 in the agent's swarm permissions and the broker socket (which tools differences live server-side in the broker socket (which tool groups
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,
permission-gated tools, self-wake, authoritative state, the tool envelope, privileged tool groups, 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,11 +22,10 @@ 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.
The agent's permissions gate tool access Tool groups (`HIVE_TOOL_GROUPS`) gate tool access. The default
([`docs/process/conventions.md#permissions`](../process/conventions.md)). preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and
The default (`Permission::DEFAULT`) is `messaging`, `meta`, `inbox` and `execution`. The privileged `lifecycle` group
`execution`; `swarmctl agent permissions set` changes it, and the next turn is opt-in via the P3RM1SS10NS tab.
runs on the change.
## Core tools (always available) ## Core tools (always available)
@ -107,7 +106,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 permission needed**: `set_status(text)`, `compact()`, **Always-on, no tool group**: `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
@ -131,23 +130,26 @@ 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 permission) ## Privileged tools (by tool group)
- **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 permission gates it. See default-on like bash execution; no tool group gates it. See
[`docs/tools/subagent.md`](../tools/subagent.md). [`docs/tools/subagent.md`](../tools/subagent.md).
- **Config changes** — no permission: they go through a forge PR on - **Lifecycle + config** (`lifecycle`) — gates nothing.
`agent-configs/<name>` — see Config changes go through a forge PR on `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. `--allowedTools` list). Off by default; add the group in the
- **Recovery mounts** (`manage_root_agent`) — no tool: every agent's state P3RM1SS10NS tab and rebuild to enable.
and config mounted into this agent's container. See - **Capabilities** — orthogonal to tool groups, set via the P3RM1SS10NS
[`docs/process/conventions.md#permissions`](../process/conventions.md). tab. No capability registers an MCP tool today; the full list and their
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).
@ -197,7 +199,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 `Permission`, not in the harness, because the `hive-sh4re` alongside `ToolGroup`, 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).
@ -205,15 +207,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.
- Permission-gated built-ins: `WebFetch`, `WebSearch` (added once the - Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added once the
agent holds `web_tools`). operator enables the `web_tools` tool group — see P3RM1SS10NS tab).
- 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 permission). - Allowed MCP tools: as listed above (by tool group).
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 +
@ -228,7 +230,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 available whenever the agent holds `execution`. `mcp__bash__*` tools are always available regardless of tool groups.
`WebFetch` / `WebSearch` are off by default; grant `web_tools` `WebFetch` / `WebSearch` are off by default; enable the `web_tools`
(`swarmctl agent permissions set`) to enable them from the next turn. tool group in the P3RM1SS10NS tab and rebuild the agent to enable them.

View file

@ -16,13 +16,14 @@ 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 four tabs: of your time. It's a single page with five 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
@ -57,10 +58,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 permission.** Not on the hive dashboard: an agent's **Grant or revoke a tool/capability.** P3RM1SS10NS is a checkbox matrix
permissions live in the swarm. Set them with `swarmctl agent permissions`, — rows are agents, columns are tool groups or capabilities. Nothing
or on the swarm UI's create-agent screen when creating the agent; see takes effect until you hit **save all** at the bottom of the tab; a
[`process/conventions.md`](../process/conventions.md#permissions). save queues a rebuild for whichever agents actually changed.
**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 ◆`. In-page tabs only — `◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. 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,15 +77,19 @@ 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`. (Rebuild queue and meta inputs are dashboard `tombstones_changed`, `capabilities_changed`, and `tool_groups_changed`
tabs — see below.) (the latter two re-render the stale-perms sub-section when permission
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** — agents with kept state and **no container** (size + age + **K3PT ST4T3** — two sub-sections on one pane:
*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}`).
@ -100,6 +104,14 @@ 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 capability or
tool-group JSON entries but no live container — typically renamed or
deleted agents whose entries persisted in `capabilities.json` /
`tool-groups.json`. Loaded when the page opens;
autorefreshes on `capabilities_changed` + `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
@ -216,6 +228,94 @@ 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 permission configuration. Two sections, each rendered as a
column-driven checkbox matrix: rows are agents, columns are the
permission names fetched from hive-c0re. The column list is
authoritative — adding a new tool-group or capability to hive-c0re
requires no UI change; the new column appears automatically.
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 `capabilities_changed` /
`tool_groups_changed` SSE events (same payload shape as the GET
endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes
without requiring navigation. Tab-activation re-fetches remain as a
safety net for reconnect windows.
**C4P4B1L1T13S** — per-agent capability grants. Capabilities unlock
gated MCP tools and system-level access beyond the default agent
surface. A saving POST queues a rebuild for the affected agent so the
new `HIVE_CAPABILITIES` env var takes effect in the next session.
The current capabilities are:
| Name | Effect |
|------|--------|
| `manage_root_agent` | mounts every agent's state (rw) + config (ro) into this agent's container so it can manage/recover any agent |
Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
boxes only stages the change in-browser; the browser writes nothing until
the operator selects the page-level **save all** button (described below). Row
values follow the `effective`/`assignments` rule described above.
<!-- 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.
Both tables share the same visual shape: `.cap-table-wrap` /
`.tg-table-wrap` outer scroll container, `thead` with a label column
(`.cap-agent-col` / `.tg-agent-col`) + one column per permission
(`.cap-col` / `.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
across *both* matrices and POSTs one batch to `POST /api/permissions` as
`{ changes: [ { agent, tool_groups?, capabilities? } ] }` — the batch
includes only the perm-types that actually changed for each agent (an
omitted field leaves that file untouched; an included array fully
replaces it).
hive-c0re coalesces an agent's capabilities + tool-groups into a
single rebuild, so changing both for one agent is one rebuild, not two.
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 both tables.
Live `capabilities_changed` / `tool_groups_changed` events re-render the
matrices unless the section 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
@ -654,6 +754,46 @@ 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.
- `GET /api/capabilities` — returns `{ caps: Vec<String>,
assignments: BTreeMap<String, Vec<String>>,
descriptions: BTreeMap<String, String> }`. `caps` is the
ordered list of all known capability names; `assignments` is the
per-agent grant map (absent agents have no extra capabilities);
`descriptions` maps each capability name to a short human-readable
string surfaced as a column-header tooltip on hover.
- `POST /api/capabilities/{agent}` — body `{ caps: ["name", …] }`.
Writes the capability set for `{agent}` to
`/var/lib/hyperhive/meta/capabilities.json` and queues a rebuild so
`HIVE_CAPABILITIES` takes effect. Agent name validated;
unknown capability strings rejected (400). `guard_agent_name`
applied.
- `POST /api/permissions` — batch perm apply for the save-all
permissions button. Body
`{ changes: [{ agent, tool_groups?: ["name", …], capabilities?: ["name", …] }] }`.
Sparse per agent: an omitted field leaves that perm-type untouched,
an empty array clears it, a populated array fully replaces it (same
replace semantics as the per-agent endpoints above). Each affected
agent gets ONE combined `PermChange` queue entry, so changing both
an agent's tool-groups and capabilities triggers a single rebuild,
not two. **Atomic**: the endpoint validates every change first (agent names via
`guard_agent_name`, group + capability names) and on any validation
error writes or enqueues nothing (non-2xx `{ error }`); it skips
rows with both fields 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:
@ -701,6 +841,18 @@ 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`).
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
capability grants. Emitted from the rebuild-queue worker after a
`PermChange` / Capabilities entry commits the JSON file. Payload
matches `GET /api/capabilities` shape so the client can call
`renderCapabilities` directly. P3RM1SS10NS tab subscribes; activation
re-fetch still runs as a safety net.
- `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,9 +13,10 @@
<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). Rebuild dashboard tab strip stays lean: kept state (tombstones) and the
queue + meta inputs are tabs of /dashboard.html. The section <div> stale permission entries. Rebuild queue + meta inputs are tabs of
id (tombstones-section) matches what core.js's renderers target. Hive /dashboard.html. The section <div> ids (tombstones-section,
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. -->
@ -25,7 +26,11 @@
</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 capability/
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.
@ -33,6 +38,7 @@
<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). // (tombstones) and the stale permission entries.
// //
// 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 { bindAsyncForms } from "@hive/shared/forms.js"; import { asyncBtn, 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,6 +95,92 @@ function renderTombstones(s) {
root.append(ul); root.append(ul);
} }
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// Agents with explicit capability / 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 capability or 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 capability and 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 });
@ -106,6 +192,14 @@ 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).
capabilities_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
}; };
// ─── boot ───────────────────────────────────────────────────────────────── // ─── boot ─────────────────────────────────────────────────────────────────
@ -132,13 +226,17 @@ async function init() {
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
await refreshState(); await refreshState();
fetchAndRenderStalePerms();
// kinds= matches SSE_HANDLERS below verbatim — narrows the stream down // kinds= matches SSE_HANDLERS below verbatim — narrows this from all
// to the kinds this page acts on. This page was one // 13 wire kinds down to the 3 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("/api/dashboard/stream?kinds=tombstones_changed"); const es = openStream(
"/api/dashboard/stream?kinds=tombstones_changed," +
"capabilities_changed,tool_groups_changed",
);
if (es) { if (es) {
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;

View file

@ -566,6 +566,155 @@ footer .banner-thin {
margin-bottom: 0.8em; margin-bottom: 0.8em;
} }
/* ─── capabilities (permissions) table ─────────────────────────────
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
as the tool-groups table below. Horizontally scrollable on narrow
viewports. */
.cap-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.cap-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.cap-table th,
.cap-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.cap-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.cap-agent-col {
text-align: left !important;
min-width: 8em;
}
.cap-col {
min-width: 5em;
}
.cap-agent-name {
color: var(--fg);
font-weight: 600;
}
.cap-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.cap-row:hover td {
background: var(--bg-elev);
}
/* ─── tool-groups (permissions) table ──────────────────────────────
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout
as the capabilities table above. 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,6 +71,18 @@
> >
<span class="tab-label">◆ BUILD L0GS ◆</span> <span class="tab-label">◆ BUILD L0GS ◆</span>
</a> </a>
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
assignments. Both tables are 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
@ -193,6 +205,75 @@
<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 capability grants + tool-group
assignments. Both tables are column-driven from the backend
(GET /api/capabilities, GET /api/tool-groups) so new entries
require no UI change. Edits stage in-browser across both
matrices; the single page-level "save all" button POSTs every
changed agent to /api/permissions in one atomic batch (caps +
groups coalesce into one rebuild per agent). -->
<section
class="tab-pane"
id="tab-pane-permissions"
data-tab-pane="permissions"
hidden
role="tabpanel"
aria-labelledby="tab-permissions"
>
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent capability grants. capabilities unlock gated MCP tools and
system access. toggle any cells across both tables, then hit "save
all" at the bottom — each changed agent rebuilds exactly once.
</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<!-- 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, coalescing caps + groups
per agent into 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 across BOTH matrices are
diffed against their render-time baselines and POSTed together to
/api/permissions; caps + groups for one agent coalesce into a
single rebuild. 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,7 +41,9 @@
<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">containers · builds</span> <span class="home-tile-desc"
>containers · builds · permissions</span
>
</a> </a>
<a class="home-tile" href="/flow.html"> <a class="home-tile" href="/flow.html">
@ -67,7 +69,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</span> <span class="home-tile-desc">kept state · stale permissions</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

@ -0,0 +1,468 @@
// Dashboard P3RM1SS10NS tab — the per-agent capabilities + tool-groups
// matrices, with a single page-level "save all" button.
//
// Both tables are fetched on tab activation (`GET /api/capabilities`,
// `GET /api/tool-groups`) and after each save. Columns (caps / groups)
// come from the backend so the UI needs no change when a new one is
// added. Live updates arrive via the `capabilities_changed` /
// `tool_groups_changed` dashboard events (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 perm-types per agent are POSTed to
// `POST /api/permissions` as `{ changes: [{agent, tool_groups?, capabilities?}] }`.
// Omitted field = leave untouched; included array = full replace. The backend
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
// double-rebuild). 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 applyCapabilitiesChanged(ev) {
const root = $("capabilities-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
renderCapabilities(root, ev);
}
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 fetchAndRenderCapabilities() {
const root = $("capabilities-section");
if (!root) return;
root.replaceChildren();
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch("/api/capabilities");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderCapabilities(root, data);
} catch (err) {
root.replaceChildren();
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
function renderCapabilities(root, data) {
root.replaceChildren();
const { caps, descriptions = {}, assignments, effective = {} } = data;
if (!caps || !caps.length) {
root.append(el("p", { class: "meta" }, "(no capabilities defined)"));
updateSaveBar();
return;
}
// Agent rows: prefer the backend roster (every manageable agent,
// default-perms included). Fall back to the live-container ∪ explicit
// union for older payloads that don't carry `agents`.
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: "cap-table-wrap" });
const table = el("table", { class: "cap-table" });
// Header row.
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "cap-agent-col" }, "agent"));
for (const c of caps) {
hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c));
}
thead.append(hrow);
table.append(thead);
const tbody = el("tbody");
for (const name of agentNames) {
// Effective caps (explicit-or-default) drive the checkboxes so
// default-perms agents show their real grants, not blank.
const assigned = effective[name] || assignments[name] || [];
// `containersState` is keyed from `nixos-container list`, which
// includes stopped-but-configured containers — so a temporarily-stopped
// agent is NOT stale. Only destroyed/renamed agents are absent here.
const isStale = !containersState.has(name);
const tr = el("tr", {
class: "cap-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el("td", { class: "cap-agent-col" });
nameTd.append(el("span", { class: "cap-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);
}
tr.append(nameTd);
// One checkbox per capability.
for (const c of caps) {
const checked = assigned.includes(c);
const td = el("td", { class: "cap-col" });
const cb = el("input", {
type: "checkbox",
class: "cap-cb",
"data-cap": c,
"data-baseline": checked ? "1" : "0",
"aria-label": c,
});
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();
}
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 that has at least
// one perm-type diverging from baseline. Only the changed perm-type(s)
// are included so untouched defaults stay defaults (an included array is
// a full replacement → omitting it leaves that file alone).
function collectChanges() {
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? }
collectSection(
$("capabilities-section"),
".cap-row",
".cap-cb",
"cap",
"capabilities",
byAgent,
);
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 both tables 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 both sections so the stale row disappears.
await Promise.all([
fetchAndRenderCapabilities(),
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 both tables 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 = "";
fetchAndRenderCapabilities();
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 and the // The roster is read by nearly every tab (the container tree, the
// selection bar), so // capabilities + tool-group matrices, and the 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,3 +199,42 @@
/* 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 capability / tool-group entries in the JSON but no
live container (renamed or manually-deleted agents). Lazy-loaded on tab
activation, refreshed on capabilities_changed / 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,6 +16,13 @@ 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 {
applyCapabilitiesChanged,
applyToolGroupsChanged,
fetchAndRenderCapabilities,
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import { import {
initJobqRollup, initJobqRollup,
syncTransientsFromSnapshot, syncTransientsFromSnapshot,
@ -54,7 +61,12 @@ 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 = ["containers-section", "inbox-section"]; const MANAGED_SECTION_IDS = [
"containers-section",
"inbox-section",
"capabilities-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
@ -198,6 +210,8 @@ window.marked = marked;
}, },
meta_inputs_changed: onMetaInputsChanged, meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning, meta_update_running: onMetaUpdateRunning,
capabilities_changed: applyCapabilitiesChanged,
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
@ -208,12 +222,13 @@ 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 7 wire kinds this page acts on. // only the 9 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," +
"capabilities_changed,tool_groups_changed",
); );
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;
@ -245,8 +260,8 @@ window.marked = marked;
})(); })();
// ─── tab routing ─────────────────────────────────────────────────────── // ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` // Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` /
// activate the matching pane on the dashboard. Empty // `#permissions` 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
@ -263,6 +278,15 @@ 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 tables: SSE covers worker-applied changes
// (capabilities_changed / 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();
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
onBuildsTabShown(target); onBuildsTabShown(target);
} }

View file

@ -25,9 +25,6 @@
// 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";
@ -47,12 +44,6 @@ 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`
@ -68,12 +59,6 @@ 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" }
@ -118,12 +103,6 @@ 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
@ -145,19 +124,6 @@ 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,
@ -180,9 +146,6 @@ 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) {
@ -192,7 +155,6 @@ 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) } });
} }
@ -214,12 +176,6 @@ 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"
@ -246,19 +202,6 @@ 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">
@ -287,7 +230,6 @@ export function CreateAgentForm({
disabled={ disabled={
result.status === "submitting" || result.status === "submitting" ||
!hivesReady || !hivesReady ||
!catalogue ||
inferenceProblem !== null inferenceProblem !== null
} }
> >

View file

@ -1,24 +0,0 @@
// 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

@ -1,31 +0,0 @@
// 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 `Permission` set); the server itself is a dumb dispatcher. //! agent's `ToolGroup` config); 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 `Permission::tools()` slice in hive-sh4re. // its name to the matching `ToolGroup::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,7 +44,6 @@ 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 the agent's permissions. `--allowedTools` blob from tool groups + capabilities.
- **`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,7 +36,6 @@ 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;
@ -497,8 +496,6 @@ 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 permission //! Claude launch-config layer: resolves the agent's tool-group / capability
//! 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,8 +6,6 @@
//! 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";
@ -20,83 +18,87 @@ 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 value for claude's `--tools` flag this turn: which built-in tools exist /// The built-in tool surface — the base list, the group-gated additions, the
/// in the session at all, from the agent's current permissions. The /// `HIVE_TOOL_GROUPS` parse, and the `--tools` value they resolve to — lives
/// resolution lives in `hive_sh4re::permissions` because the subagent daemon /// in `hive_sh4re::permissions`, next to `ToolGroup` itself. Re-exported here
/// (`hive-subagent-mcp`) spawns its own `claude` from the same one. /// because this module is where the rest of the claude launch config is
#[must_use] /// assembled, and because the subagent daemon (`hive-subagent-mcp`) spawns
pub fn builtin_tools_arg() -> String { /// its own `claude` from the same base resolution (plus its own `Bash`
hive_sh4re::permissions::builtin_tools_for(&crate::swarm_permissions::current()).join(",") /// exception — see `hive_sh4re::permissions::subagent_builtin_tools_arg`):
} /// `hive-agent` is a binary-only crate with no lib target, so a shared home
/// was the only way for both spawners to read one list rather than two that
/// drift.
pub use hive_sh4re::permissions::{builtin_tools_arg, effective_tool_groups};
/// Permission an extra (out-of-process) MCP server is gated behind, if any. /// Tool group 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 /// execution is a privilege, so it is only exposed when the agent holds the
/// `Execution`. Unlike the in-process hyperhive tools (gated at dispatch), /// `Execution` tool group. Unlike the in-process hyperhive tools (gated at
/// 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_permission(server: &str) -> Option<Permission> { fn extra_server_required_group(server: &str) -> Option<hive_sh4re::permissions::ToolGroup> {
match server { match server {
"bash" => Some(Permission::Execution), "bash" => Some(hive_sh4re::permissions::ToolGroup::Execution),
_ => None, _ => None,
} }
} }
/// Whether an extra MCP server should be exposed to claude given the agent's /// Whether an extra MCP server should be exposed to claude given the active
/// `perms`. A gated server (see [`extra_server_required_permission`]) is /// tool `groups`. A gated server (see [`extra_server_required_group`]) is
/// suppressed when the agent lacks its required permission. /// suppressed when the agent lacks its required group.
fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool { fn extra_server_enabled(server: &str, groups: &[hive_sh4re::permissions::ToolGroup]) -> bool {
extra_server_required_permission(server).is_none_or(|required| perms.contains(&required)) extra_server_required_group(server).is_none_or(|required| groups.contains(&required))
} }
#[cfg(test)] #[cfg(test)]
mod extra_server_gate_tests { mod extra_server_gate_tests {
use super::{extra_server_enabled, extra_server_required_permission}; use super::{extra_server_enabled, extra_server_required_group};
use hive_sh4re::permissions::Permission; use hive_sh4re::permissions::ToolGroup;
#[test] #[test]
fn bash_is_gated_behind_execution() { fn bash_is_gated_behind_execution() {
assert_eq!( assert_eq!(
extra_server_required_permission("bash"), extra_server_required_group("bash"),
Some(Permission::Execution) Some(ToolGroup::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",
&[Permission::Messaging, Permission::Inbox] &[ToolGroup::Messaging, ToolGroup::Inbox]
)); ));
// Available once Execution is granted. // Available once Execution is granted.
assert!(extra_server_enabled("bash", &[Permission::Execution])); assert!(extra_server_enabled("bash", &[ToolGroup::Execution]));
} }
#[test] #[test]
fn other_servers_are_ungated() { fn other_servers_are_ungated() {
assert_eq!(extra_server_required_permission("matrix"), None); assert_eq!(extra_server_required_group("matrix"), None);
assert_eq!(extra_server_required_permission("scraper"), None); assert_eq!(extra_server_required_group("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", &[Permission::Messaging])); assert!(extra_server_enabled("scraper", &[ToolGroup::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 permissions. Adding a new `#[tool]` fn to a server impl /// the supplied tool groups. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `Permission::tools()` slice in hive-sh4re /// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re
/// (single source of truth). /// (single source of truth). See `docs/process/conventions.md::Tool groups`.
#[must_use] #[must_use]
pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec<String> { pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> 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 permissions the agent holds — a misconfigured // regardless of which groups the agent is granted — 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> = Permission::ALWAYS_ON_TOOLS let mut out: Vec<String> = hive_sh4re::permissions::ToolGroup::ALWAYS_ON_TOOLS
.iter() .iter()
.copied() .copied()
.chain(perms.iter().flat_map(|p| p.tools().iter().copied())) .chain(groups.iter().flat_map(|g| g.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();
@ -106,7 +108,7 @@ pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec<String> {
// 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, perms) { if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
continue; continue;
} }
for pat in spec.allowed_tools() { for pat in spec.allowed_tools() {
@ -120,14 +122,14 @@ pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec<String> {
/// 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 perms = crate::swarm_permissions::current(); let groups = effective_tool_groups();
// 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(&perms) let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&groups)
.into_iter() .into_iter()
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.collect(); .collect();
all.extend(allowed_mcp_tools(&perms)); all.extend(allowed_mcp_tools(&groups));
all.join(",") all.join(",")
} }
@ -146,7 +148,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 permitted extra servers). The harness compares /// any tool-group-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).
@ -156,7 +158,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 permitted extra /// built-in hyperhive HTTP surface plus any tool-group-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.
@ -185,11 +187,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 permission-restricted extra servers (e.g. `bash` → `Execution`). // Gate tool-group-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 perms = crate::swarm_permissions::current(); let groups = effective_tool_groups();
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!(
@ -197,10 +199,10 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
); );
continue; continue;
} }
if !extra_server_enabled(&name, &perms) { if !extra_server_enabled(&name, &groups) {
tracing::info!( tracing::info!(
server = %name, server = %name,
"extra MCP server suppressed: agent lacks the required permission" "extra MCP server suppressed: agent lacks the required tool group"
); );
continue; continue;
} }
@ -212,7 +214,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::Permission; use hive_sh4re::permissions::ToolGroup;
fn qualified(tool: &str) -> String { fn qualified(tool: &str) -> String {
format!("mcp__{SERVER_NAME}__{tool}") format!("mcp__{SERVER_NAME}__{tool}")
@ -220,7 +222,7 @@ mod tests {
#[test] #[test]
fn set_status_present_with_no_groups() { fn set_status_present_with_no_groups() {
// An agent with no permissions (or any set that omits // An agent with zero tool groups (or any group 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!(
@ -232,7 +234,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 group, so no agent could ever // tool but never added to any ToolGroup, 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.
@ -241,13 +243,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(&[Permission::Inbox]); let tools = allowed_mcp_tools(&[ToolGroup::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(&[Permission::Messaging, Permission::Inbox]); let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::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")));
@ -255,7 +257,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(&[Permission::Meta]); let tools = allowed_mcp_tools(&[ToolGroup::Meta]);
let count = tools let count = tools
.iter() .iter()
.filter(|t| **t == qualified("set_status")) .filter(|t| **t == qualified("set_status"))

View file

@ -1,181 +0,0 @@
//! 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, the agent holds what
//! hive-c0re stored for it ([`HiveCopy::before_read`]), so a queue that is down
//! or slow leaves it on its last known grant, not the default. A read is
//! judged by [`HiveCopy::after_read`]: an unset key is the default, except
//! while the hive's import is pending. A failed read keeps the last value.
use std::path::Path;
use std::sync::{PoisonError, RwLock};
use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{HIVE_COPY_PATH, HiveCopy, 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 succeeds.
static CURRENT: RwLock<Option<Vec<Permission>>> = RwLock::new(None);
/// The permissions this agent holds now.
#[must_use]
pub fn current() -> Vec<Permission> {
let last = CURRENT
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone();
current_with(last, Path::new(HIVE_COPY_PATH), &crate::identity::label())
}
/// [`current`] with the last read and the hive's copy given.
fn current_with(last: Option<Vec<Permission>>, copy: &Path, agent: &str) -> Vec<Permission> {
last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read(agent))
}
/// What one read of the key leaves the agent holding, judged against the
/// hive's copy at `copy`; `None` (keep the last value) when the read failed
/// or the bucket did not answer.
fn after(agent: &str, copy: &Path, read: Option<Read>) -> Option<Vec<Permission>> {
match read? {
Read::Current(record) => Some(HiveCopy::read_at(copy).after_read(agent, &record)),
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 {
let read = read_once(&client, &agent).await;
if let Some(perms) = after(&agent, Path::new(HIVE_COPY_PATH), read) {
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"))
}
/// A hive's copy holding `json`, in a tempdir kept alive by the guard.
fn copy(json: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("swarm-permissions.json");
std::fs::write(&path, json).expect("write the copy");
(dir, path)
}
#[test]
fn a_read_sets_the_list_or_the_default_and_a_failure_keeps_the_last() {
let none = Path::new("/nonexistent/swarm-permissions.json");
assert_eq!(
after(
"atlas",
none,
Some(record(r#"{"permissions":["web_tools"]}"#))
),
Some(vec![Permission::WebTools])
);
assert_eq!(
after("atlas", none, Some(record(r#"{"permissions":[]}"#))),
Some(Vec::new())
);
assert_eq!(
after("atlas", none, Some(record("{}"))),
Some(Permission::DEFAULT.to_vec())
);
assert_eq!(after("atlas", none, Some(Read::NoBucket)), None);
assert_eq!(after("atlas", none, None), None);
}
#[test]
fn before_the_first_read_the_stored_grant_applies() {
let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#);
assert_eq!(
current_with(None, &path, "atlas"),
vec![Permission::WebTools]
);
let (_dir, path) = copy(r#"{"permissions":[]}"#);
assert_eq!(current_with(None, &path, "atlas"), Vec::new());
assert_eq!(
current_with(Some(vec![Permission::Meta]), &path, "atlas"),
vec![Permission::Meta],
"a read outranks the copy"
);
}
#[test]
fn while_the_import_is_pending_an_unset_key_keeps_the_stored_grant() {
let (_dir, path) = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#);
assert_eq!(
after("atlas", &path, Some(record("{}"))),
Some(vec![Permission::WebTools])
);
assert_eq!(
after("atlas", &path, Some(record(r#"{"permissions":["meta"]}"#))),
Some(vec![Permission::Meta]),
"a set key still wins"
);
let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#);
assert_eq!(
after("atlas", &path, Some(record("{}"))),
Some(Permission::DEFAULT.to_vec()),
"once imported, unset is the default"
);
}
/// No read ever succeeds: the last value stays unset and the agent holds
/// the hive's stored grant.
#[test]
fn with_the_queue_down_the_agent_holds_the_stored_grant() {
let (_dir, path) = copy(r#"{"permissions":["messaging"],"import_pending":true}"#);
let mut last = None;
for read in [None, Some(Read::NoBucket), None] {
if let Some(perms) = after("atlas", &path, read) {
last = Some(perms);
}
}
assert_eq!(
current_with(last, &path, "atlas"),
vec![Permission::Messaging]
);
}
}

View file

@ -91,12 +91,10 @@ 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 at harness startup; shared between the turn loop and the /// Materialised once at harness startup; shared between the turn loop
/// operator-driven `/compact` path so both invocations look identical to /// and the operator-driven `/compact` path so both invocations look
/// claude (same MCP surface, same allowed tools, same role prompt — only the /// identical to claude (same MCP surface, same allowed tools, same
/// stdin payload differs). The MCP config is rewritten before each /// role prompt — only the stdin payload differs).
/// 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,
@ -116,16 +114,6 @@ 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>`.
@ -347,25 +335,22 @@ 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 {
std::sync::Arc::new(|ask: &PermissionAsk<'_>| { let web =
let web = crate::swarm_permissions::current() mcp_config::effective_tool_groups().contains(&hive_sh4re::permissions::ToolGroup::WebTools);
.contains(&hive_sh4re::permissions::Permission::WebTools); std::sync::Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, web))
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 permissions /// — which of those an agent gets is already decided by its tool groups
/// (`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` permission (`web`). /// - `fetch` with the `web_tools` group (`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.
@ -410,7 +395,6 @@ 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;
@ -637,7 +621,6 @@ 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

@ -57,7 +57,6 @@ swarm-queue-client = { workspace = true, features = [
"kv", "kv",
"notices", "notices",
"agent-limits", "agent-limits",
"agent-permissions",
] } ] }
swarm-secret-client.workspace = true swarm-secret-client.workspace = true
tokio.workspace = true tokio.workspace = true

View file

@ -34,8 +34,8 @@ to track it:
- **`stores/`** — sqlite-backed stores (broker, queues, audit, power). - **`stores/`** — sqlite-backed stores (broker, queues, audit, power).
- **`workers/`** — background sweeps (crash watch, - **`workers/`** — background sweeps (crash watch,
autoupdate, knowledge sync). autoupdate, knowledge sync).
- **`agent_config/`** — per-agent registries (permissions, - **`agent_config/`** — per-agent registries (tool groups,
resource limits, topology). capabilities, resource limits, topology).
- **`stats/`** — dashboard metrics aggregation + OTEL export. - **`stats/`** — dashboard metrics aggregation + OTEL export.
- **`socket_server/`** — the unix-socket request server shared by - **`socket_server/`** — the unix-socket request server shared by
per-agent + manager sockets. per-agent + manager sockets.

View file

@ -0,0 +1,262 @@
//! Per-agent capability configuration. Stored at
//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json`
//! and `tool-groups.json`.
//!
//! Format: a JSON object mapping agent name to an array of
//! `hive_sh4re::permissions::Capability` `snake_case` strings:
//!
//! ```json
//! {
//! "atlas": ["manage_root_agent"]
//! }
//! ```
//!
//! An absent entry (or an absent file) means "no extra capabilities".
//! `render_flake` in `meta.rs` reads this file and injects
//! `HIVE_CAPABILITIES` into each agent's systemd service env; absent
//! entries emit no env var so agents without capabilities don't trigger
//! a spurious rebuild.
//!
//! Write path: `set_caps` is called from the dashboard action handler
//! that the operator uses to grant/revoke capabilities per agent.
use hive_sh4re::permissions::Capability;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const CAPABILITIES_FILE: &str = "capabilities.json";
#[must_use]
pub fn capabilities_path() -> PathBuf {
crate::paths::meta_root().join(CAPABILITIES_FILE)
}
/// True if `name` matches a recognised [`Capability`], case-insensitive
/// (same comparison [`has_cap`] uses).
#[must_use]
fn is_known(name: &str) -> bool {
Capability::ALL
.iter()
.any(|cap| name.eq_ignore_ascii_case(<&str>::from(*cap)))
}
/// Drop any capability string in `map` that isn't a recognised
/// [`Capability`], warning per dropped string so a typo'd or stale
/// grant doesn't look like it worked. An agent left with no
/// capabilities is removed entirely, matching [`set_caps`]'s
/// empty-vec behaviour. Returns whether anything was dropped.
fn prune_unknown(map: &mut BTreeMap<String, Vec<String>>) -> bool {
let mut dropped_any = false;
map.retain(|agent, caps| {
caps.retain(|cap| {
let known = is_known(cap);
if !known {
dropped_any = true;
tracing::warn!(agent, capability = %cap, "dropping unrecognised capability");
}
known
});
!caps.is_empty()
});
dropped_any
}
/// Read the per-agent capability map. An absent file is the empty map —
/// callers treat a missing entry as "no extra capabilities". A file
/// that exists but can't be read or parsed is an error. Any entry that
/// isn't a recognised [`Capability`] is dropped (with a `warn!`) from
/// what's returned — a stale or typo'd name is never honoured — but
/// `read` itself never writes. Unknown names stay on disk until the next
/// write: `set_caps` always rewrites the file, `remove_agent` only when
/// the agent had an entry.
pub fn read() -> std::io::Result<BTreeMap<String, Vec<String>>> {
read_from(&capabilities_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)
}
/// Look up the configured capabilities for one agent in the file at
/// `path` (normally [`capabilities_path`]). Returns an empty vec when
/// the agent has no entry. Errors as [`read`] does.
pub fn caps_for(path: &Path, name: &str) -> std::io::Result<Vec<String>> {
Ok(read_from(path)?.get(name).cloned().unwrap_or_default())
}
/// Check whether an agent holds a specific capability, per the file at
/// `path` (normally [`capabilities_path`]). Errors as [`read`] does.
pub fn has_cap(
path: &Path,
name: &str,
cap: hive_sh4re::permissions::Capability,
) -> std::io::Result<bool> {
Ok(caps_for(path, name)?
.iter()
.any(|s| s.eq_ignore_ascii_case(<&str>::from(cap))))
}
/// Filter `caps` down to recognised [`Capability`] names (warning per
/// drop) and apply the result to `current` for `name`: an empty
/// result — whether `caps` started empty or every name in it was
/// unknown — removes the entry, matching `read`'s empty-entry
/// convention. Split out from [`set_caps`] so the decision logic is
/// testable without touching the real capabilities file.
fn apply_known_caps(current: &mut BTreeMap<String, Vec<String>>, name: &str, caps: &[String]) {
let known: Vec<String> = caps
.iter()
.filter(|cap| {
let ok = is_known(cap);
if !ok {
tracing::warn!(agent = name, capability = %cap, "dropping unrecognised capability");
}
ok
})
.cloned()
.collect();
if known.is_empty() {
current.remove(name);
} else {
current.insert(name.to_owned(), known);
}
}
/// Set the capabilities for one agent and persist the map. Any name
/// that isn't a recognised [`Capability`] is dropped (with a `warn!`)
/// rather than written — an unknown grant should never look like it
/// took effect. An empty `caps` vec, or one that becomes empty after
/// dropping unknown names, removes the entry (agent has no
/// capabilities). Fails without writing if the existing file can't be
/// read or parsed.
pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> {
set_caps_at(&capabilities_path(), name, caps)
}
fn set_caps_at(path: &Path, name: &str, caps: &[String]) -> std::io::Result<()> {
let mut current = read_from(path)?;
apply_known_caps(&mut current, name, caps);
super::write_map(path, &current)
}
/// Remove an agent from the capability map entirely. Called by
/// `meta::sync_agents` when an agent is deprovisioned so stale entries
/// don't accumulate. No-op if the agent has no entry. 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(&capabilities_path(), name)
}
fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> {
let mut current = read_from(path)?;
if current.remove(name).is_some() {
super::write_map(path, &current)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// `read`/`set_caps`/`remove_agent` call `crate::paths::meta_root`,
// which is hardcoded to `/var/lib/hyperhive` (no test override) — so
// these tests pin the pure decision logic (`prune_unknown`,
// `apply_known_caps`) against in-memory maps, and the file handling
// through the path-taking `*_at` / `read_from` variants.
const TRUNCATED: &str = "{\n \"atlas\": [\"manage_root_ag";
fn corrupt_file() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(CAPABILITIES_FILE);
std::fs::write(&path, TRUNCATED).expect("seed");
(dir, path)
}
#[test]
fn read_of_a_corrupt_file_is_an_error() {
let (_dir, path) = corrupt_file();
read_from(&path).expect_err("truncated JSON must not read as a map");
}
#[test]
fn set_caps_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let err = set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()])
.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 remove_agent_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let err =
remove_agent_at(&path, "atlas").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_caps_round_trips_through_a_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(CAPABILITIES_FILE);
assert!(read_from(&path).expect("missing file is empty").is_empty());
set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]).expect("set");
let map = read_from(&path).expect("read");
assert_eq!(map["ruth"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn prune_unknown_keeps_known_name() {
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
assert!(!prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn prune_unknown_drops_unrecognised_name() {
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["fly_to_the_moon".to_owned()])]);
assert!(prune_unknown(&mut map));
assert!(!map.contains_key("atlas"));
}
#[test]
fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() {
let mut map = BTreeMap::from([(
"atlas".to_owned(),
vec!["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
)]);
assert!(prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn apply_known_caps_removes_entry_on_empty_input() {
let mut current =
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
apply_known_caps(&mut current, "atlas", &[]);
assert!(!current.contains_key("atlas"));
}
#[test]
fn apply_known_caps_removes_entry_when_only_unknown_names_given() {
let mut current =
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
apply_known_caps(&mut current, "atlas", &["fly_to_the_moon".to_owned()]);
assert!(!current.contains_key("atlas"));
}
#[test]
fn apply_known_caps_keeps_only_the_known_name() {
let mut current = BTreeMap::new();
apply_known_caps(
&mut current,
"atlas",
&["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
);
assert_eq!(current["atlas"], vec!["manage_root_agent".to_owned()]);
}
}

View file

@ -1,16 +1,17 @@
//! Per-agent configuration registries: resource limits, topology (JSON files //! Per-agent configuration registries: tool groups, capabilities,
//! under `/var/lib/hyperhive/meta/`), the permissions the swarm grants //! resource limits, topology (all JSON files under
//! (outside the meta repo) and the shared wire-protocol size limits. Each //! `/var/lib/hyperhive/meta/`) and the shared wire-protocol size
//! submodule is re-exported at the crate root, so `crate::topology::…` etc. //! limits. Each submodule is re-exported at the crate root, so
//! keep working unchanged. //! `crate::topology::…` etc. 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,148 +0,0 @@
//! The permissions the swarm grants this hive's agents, as last read from the
//! `agent-permissions` bucket by `workers::swarm_permissions`. Stored at
//! `/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo:
//! the swarm is the record, this file is the hive's last read of it.
//!
//! Format: agent name → the stored
//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with
//! no entry, or an entry with `permissions` unset, holds
//! [`Permission::default_for`]; `"permissions": []` holds none.
//!
//! hive-c0re enforces one member, [`Permission::ManageRootAgent`]: read by
//! `lifecycle::host_config::set_nspawn_flags` on every container start, which
//! is why a change to it takes a restart. The agent reads the tool members
//! itself, and holds its entry here until its own first read succeeds:
//! [`hand_at`] copies the entry into the agent's runtime dir.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{AgentPermissions, HIVE_COPY_FILE, HiveCopy};
const FILE: &str = "swarm-agent-permissions.json";
pub type Stored = BTreeMap<String, AgentPermissions>;
#[must_use]
pub fn path() -> PathBuf {
crate::paths::state_root().join(FILE)
}
/// Read the stored permissions. An absent file is the empty map: every agent
/// on its default. A file that exists but can't be read or parsed is an error.
pub fn read_at(path: &Path) -> std::io::Result<Stored> {
super::read_map(path)
}
/// Persist the permissions atomically.
pub fn write_at(path: &Path, stored: &Stored) -> std::io::Result<()> {
super::write_map(path, stored)
}
/// The permissions `agent` holds under `record`.
#[must_use]
pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Permission> {
record
.and_then(|r| r.permissions.clone())
.unwrap_or_else(|| Permission::default_for(agent))
}
/// Whether `agent` holds `perm` per the file at `path` (normally [`path`]).
///
/// # Errors
///
/// As [`read_at`].
pub fn holds(path: &Path, agent: &str, perm: Permission) -> std::io::Result<bool> {
Ok(effective(agent, read_at(path)?.get(agent)).contains(&perm))
}
/// Write `agent`'s [`HiveCopy`] into `runtime_dir`: its entry in the file at
/// `stored` (normally [`path`]) and `import_pending`. Left alone when the
/// copy there already says the same.
///
/// # Errors
///
/// As [`read_at`], or the write failed.
pub fn hand_at(
stored: &Path,
import_pending: bool,
agent: &str,
runtime_dir: &Path,
) -> std::io::Result<()> {
let copy = HiveCopy {
stored: read_at(stored)?.remove(agent).unwrap_or_default(),
import_pending,
};
let dest = runtime_dir.join(HIVE_COPY_FILE);
if dest.exists() && HiveCopy::read_at(&dest) == copy {
return Ok(());
}
super::write_map(&dest, &copy)
}
#[cfg(test)]
mod tests {
use super::*;
fn record(json: &str) -> AgentPermissions {
serde_json::from_str(json).expect("decodes")
}
#[test]
fn unset_is_the_default_and_an_empty_list_is_none() {
assert!(effective("ruth", None).contains(&Permission::ManageRootAgent));
assert!(effective("ruth", Some(&record("{}"))).contains(&Permission::ManageRootAgent));
assert!(effective("ruth", Some(&record(r#"{"permissions":[]}"#))).is_empty());
assert_eq!(effective("atlas", None), Permission::DEFAULT.to_vec());
assert_eq!(
effective(
"atlas",
Some(&record(r#"{"permissions":["manage_root_agent"]}"#))
),
vec![Permission::ManageRootAgent]
);
}
#[test]
fn a_missing_file_holds_the_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
assert!(holds(&path, "ruth", Permission::ManageRootAgent).expect("missing is empty"));
assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("missing is empty"));
}
/// With the queue down nothing is read, so the agent starts on the copy
/// written here: the stored grant, not the default.
#[test]
fn the_agent_is_handed_its_stored_grant_and_whether_the_import_is_pending() {
let dir = tempfile::tempdir().expect("tempdir");
let stored = dir.path().join(FILE);
let runtime = dir.path().join("run");
write_at(
&stored,
&Stored::from([(
"atlas".to_owned(),
record(r#"{"permissions":["web_tools"]}"#),
)]),
)
.expect("seed");
hand_at(&stored, true, "atlas", &runtime).expect("hand");
let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE));
assert!(copy.import_pending);
assert_eq!(copy.before_read("atlas"), vec![Permission::WebTools]);
hand_at(&stored, false, "iris", &runtime).expect("hand");
let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE));
assert_eq!(copy, HiveCopy::default(), "no entry is the default");
}
#[test]
fn a_corrupt_file_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
std::fs::write(&path, "{\n \"ruth\": {\"permissions\": [\"manage_root_ag").expect("seed");
holds(&path, "ruth", Permission::ManageRootAgent).expect_err("truncated JSON");
}
}

View file

@ -3,7 +3,7 @@
//! //!
//! The swarm caps are stored at //! 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`. //! `topology.json`, `tool-groups.json` and `capabilities.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

@ -0,0 +1,245 @@
//! 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,12 +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::Permission::ManageRootAgent`] holder gets //! [`hive_sh4re::permissions::Capability::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` permission the swarm grants //! hang off the `ManageRootAgent` capability in `capabilities.json`. See
//! (`swarm_permissions`). See
//! `lifecycle::set_nspawn_flags`. //! `lifecycle::set_nspawn_flags`.
use std::collections::BTreeSet; use std::collections::BTreeSet;
@ -65,8 +64,8 @@ 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::Permission::ManageRootAgent`] holder gets /// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
/// bind-mounted, and it is deliberately unfiltered: that permission 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.
/// ///
/// An unreadable roster is logged and yields no agents, so the holder /// An unreadable roster is logged and yields no agents, so the holder

View file

@ -552,6 +552,87 @@ 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 `CapabilitiesChanged` snapshot event. Called from the
/// rebuild-queue worker after a `PermChange` / Capabilities entry
/// commits the JSON file, so the P3RM1SS10NS tab updates live.
pub fn emit_capabilities_snapshot(self: &Arc<Self>) {
use hive_sh4re::permissions::Capability;
let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect();
let descriptions = Capability::ALL
.iter()
.map(|c| (<&str>::from(*c), c.description()))
.collect();
// An empty snapshot would show every agent as having lost its
// capabilities; emit nothing and let the HTTP refetch report it.
let assignments = match crate::capabilities::read() {
Ok(map) => map,
Err(e) => {
tracing::warn!(error = ?e, "capabilities snapshot skipped");
return;
}
};
// Best-effort roster (sync path); on a contended cache miss we
// emit explicit keys only — the HTTP refetch fills the rest in.
let roster = self.live_container_names_blocking().unwrap_or_default();
// Capability default is "no extra caps" — empty default slice.
let (agents, effective) =
crate::dashboard::permissions::roster_and_effective(roster, &assignments, &[]);
self.emit_dashboard_event(DashboardEvent::CapabilitiesChanged {
seq: self.next_seq(),
caps,
descriptions,
assignments,
agents,
effective,
});
}
/// 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,6 +37,7 @@ 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 + capability 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"),
@ -55,6 +56,7 @@ 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;
@ -122,6 +124,13 @@ 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::get_capabilities))
.routes(routes!(permissions::post_capabilities))
.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))
@ -369,6 +378,13 @@ 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::get_capabilities))
.routes(routes!(permissions::post_capabilities))
.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

@ -0,0 +1,567 @@
//! Tool-group + capability permission endpoints for the dashboard.
//!
//! Read endpoints return the full set of known groups/capabilities 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 capabilities 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 capabilities + 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())
}
#[derive(Serialize, ToSchema)]
pub(super) struct CapabilitiesSnapshot {
/// Ordered list of all known capability names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them.
caps: Vec<&'static str>,
/// Short description for each capability name. Keys match `caps` entries.
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent *explicit* capability grant map. Absent agents have no
/// extra caps — the UI uses presence here to badge "(default)".
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Every agent the operator can manage (live roster ∪ explicit keys),
/// sorted — so default agents always list.
agents: Vec<String>,
/// Per-agent *effective* caps: explicit entry when present, else the
/// default (no caps). Keeps the snapshot shape symmetric with
/// tool-groups; the default here is always empty.
effective: std::collections::BTreeMap<String, Vec<String>>,
}
/// Every known capability name + description,
/// plus the per-agent explicit/effective grant maps.
#[utoipa::path(
get,
path = "/api/capabilities",
responses(
(status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot),
(status = 500, description = "capabilities file unreadable"),
),
tag = "permissions"
)]
pub(super) async fn get_capabilities(
State(state): State<AppState>,
) -> Result<axum::Json<CapabilitiesSnapshot>, ProblemDetails> {
use hive_sh4re::permissions::Capability;
let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect();
let descriptions = Capability::ALL
.iter()
.map(|c| (<&str>::from(*c), c.description()))
.collect();
let assignments = crate::capabilities::read().map_err(|e| unreadable(&e))?;
let roster = state
.coord
.containers_snapshot()
.await
.into_iter()
.map(|c| c.name);
// Capability default is "no extra caps" — empty default slice.
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
Ok(axum::Json(CapabilitiesSnapshot {
caps,
descriptions,
assignments,
agents,
effective,
}))
}
#[derive(Deserialize, ToSchema)]
pub(super) struct SetCapabilitiesBody {
caps: Vec<String>,
}
/// Replace `agent`'s explicit capability
/// grant set (JSON body `{"caps": [...]}`).
#[utoipa::path(
post,
path = "/api/capabilities/{agent}",
params(("agent" = String, Path, description = "agent name")),
request_body = SetCapabilitiesBody,
responses(
(status = 200, description = "capabilities queued for write", body = String),
(status = 400, description = "unknown capability name"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_capabilities(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetCapabilitiesBody>,
) -> Result<Response, ProblemDetails> {
let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
}
let known: Vec<&str> = hive_sh4re::permissions::Capability::ALL
.iter()
.map(|c| <&str>::from(*c))
.collect();
for cap in &body.caps {
if !known.contains(&cap.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown capability: {cap}")));
}
}
// 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 capabilities.json.
state
.coord
.job_queue
.insert_job(|b| {
crate::job_queue::templates::perm_change(
b,
&logical,
crate::job_queue::PermPayload::Capabilities {
caps: body.caps.clone(),
},
);
Vec::new()
})
.expect("template-declared shapes are acyclic");
state.coord.emit_rebuild_queue_snapshot();
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
Ok((StatusCode::OK, "ok").into_response())
}
/// One agent's slice of a batch permission change. Sparse: an omitted
/// field leaves that perm-type untouched, an empty array clears it, a
/// populated array fully replaces it (same replace semantics as the
/// per-agent endpoints).
#[derive(Deserialize, ToSchema)]
pub(super) struct PermChangeBody {
agent: String,
#[serde(default)]
tool_groups: Option<Vec<String>>,
#[serde(default)]
capabilities: Option<Vec<String>>,
}
#[derive(Deserialize, ToSchema)]
pub(super) struct BatchPermsBody {
changes: Vec<PermChangeBody>,
}
/// A validated, non-empty change staged for enqueue:
/// `(logical agent, new groups?, new caps?)`.
type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
/// Batch permission apply — `POST /api/permissions`.
///
/// The save-all permissions UI sends only the perm-types that actually
/// changed per agent; each affected agent gets ONE combined
/// `PermChange`, so the dedup key collapses to `(kind, agent)` and an
/// agent whose caps AND groups both changed rebuilds once, not twice.
/// 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/capability 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> {
let known_caps: Vec<&str> = hive_sh4re::permissions::Capability::ALL
.iter()
.map(|c| <&str>::from(*c))
.collect();
// 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 (both fields omitted) are skipped, not errors.
let mut staged: Vec<StagedPerm> = 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
&& 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}")));
}
if let Some(caps) = &change.capabilities {
for cap in caps {
if !known_caps.contains(&cap.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown capability for {logical}: {cap}")));
}
}
}
if change.tool_groups.is_some() || change.capabilities.is_some() {
staged.push((
logical,
change.tool_groups.clone(),
change.capabilities.clone(),
));
}
}
// Phase 2 — submit one combined PermChange DAG per affected agent.
for (logical, groups, caps) in staged {
state
.coord
.job_queue
.insert_job(|b| {
crate::job_queue::templates::perm_change(
b,
&logical,
crate::job_queue::PermPayload::Combined { groups, caps },
);
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 capability/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/capabilities 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();
// Explicit entries in either JSON file.
let caps = crate::capabilities::read().map_err(|e| unreadable(&e))?;
let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
let mut ghost_names: Vec<String> = caps
.keys()
.chain(tgs.keys())
.filter(|n| !known.contains(n))
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
ghost_names.sort();
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` / `capabilities.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 snapshots
/// update 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/capabilities 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();
}
};
// Run both removals regardless so we clean up as much as possible
// even on partial I/O errors. Collect errors to surface below.
let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err();
if let Some(ref e) = tg_err {
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
}
let cap_err = crate::capabilities::remove_agent(logical.as_str()).err();
if let Some(ref e) = cap_err {
tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry");
}
// Emit live snapshots even on partial failure so the UI stays as
// accurate as possible — the surviving table gets updated immediately.
state.coord.emit_tool_groups_snapshot();
state.coord.emit_capabilities_snapshot();
// Surface any 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 Some(e) = tg_err.or(cap_err) {
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,6 +141,42 @@ 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 capability grants (per-agent `Vec<cap_name>`).
/// Emitted from the rebuild-queue worker after a `PermChange`
/// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS
/// tab update live when the worker applies a queued change.
CapabilitiesChanged {
seq: u64,
/// Ordered list of all known capability names (column headers).
caps: Vec<&'static str>,
/// Short description for each capability name (tooltip).
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent *explicit* capability grant map; absent agents have
/// no extra caps (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* caps (explicit-or-default; default empty).
effective: std::collections::BTreeMap<String, Vec<String>>,
},
/// 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 {
@ -167,6 +203,8 @@ 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::CapabilitiesChanged { .. } => "capabilities_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
} }
} }
} }
@ -241,6 +279,22 @@ mod tests {
running: false, running: false,
}, },
DashboardEvent::RebuildQueueChanged { seq: 1 }, DashboardEvent::RebuildQueueChanged { seq: 1 },
DashboardEvent::CapabilitiesChanged {
seq: 1,
caps: Vec::new(),
descriptions: std::collections::BTreeMap::new(),
assignments: std::collections::BTreeMap::new(),
agents: Vec::new(),
effective: std::collections::BTreeMap::new(),
},
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,6 +120,7 @@ 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(())
@ -789,6 +790,47 @@ 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();
}
PermPayload::Capabilities { caps } => {
crate::meta::commit_capabilities(name, caps)
.await
.with_context(|| format!("commit capabilities for {name}"))?;
coord.emit_capabilities_snapshot();
}
PermPayload::Combined { groups, caps } => {
crate::meta::commit_perms(name, groups.as_deref(), caps.as_deref())
.await
.with_context(|| format!("commit perms for {name}"))?;
if groups.is_some() {
coord.emit_tool_groups_snapshot();
}
if caps.is_some() {
coord.emit_capabilities_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, State}; pub use model::{NodeKind, PermPayload, 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` wire //! operations — and what each one carries. The `State` / `PermPayload` wire
//! enum lives in `hive_host_sock::jobs` (it travels on the host admin //! enums live in `hive_host_sock::jobs` (they travel on the host admin
//! socket) and is re-exported here for the queue's internal use. The graph //! socket) and are 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::State; pub use hive_host_sock::jobs::{PermPayload, 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,10 +98,12 @@ 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` / `capabilities.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: emit this agent's `Rebuilt` /// Tail node of a rebuild / perm-change: 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).
@ -183,6 +185,7 @@ 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; use super::model::{NodeKind, PermPayload};
use super::resource::Resource; use super::resource::Resource;
use super::{Handle, JobBuilder}; use super::{Handle, JobBuilder};
@ -403,6 +403,26 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
.after_ok(last); .after_ok(last);
} }
/// Perm change: commit the JSON file(s), then the rebuild subgraph so
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` 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,6 +1515,41 @@ 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::Combined {
groups: Some(vec![]),
caps: None,
},
);
});
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

@ -1,13 +1,13 @@
//! Per-container host-side config: the nspawn conf rewrite (bind mounts, //! Per-container host-side config: the nspawn conf rewrite (bind mounts,
//! network isolation, forwarded credentials), the systemd resource-limits //! network isolation, forwarded credentials), the systemd resource-limits
//! drop-in, the agent's copies of its memory cap and stored permissions, and //! drop-in, the agent's copy of its memory cap, and the `write_dropins` verb
//! the `write_dropins` verb that re-applies them. //! that re-applies them.
use std::path::{Path, PathBuf}; 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::Permission; use hive_sh4re::permissions::Capability;
use crate::coordinator::{AgentPaths, HiveEnv}; use crate::coordinator::{AgentPaths, HiveEnv};
@ -17,16 +17,14 @@ use super::{
}; };
/// Re-apply the per-container host-side config: nspawn flags (bind /// Re-apply the per-container host-side config: nspawn flags (bind
/// mounts etc.), the systemd resource-limits drop-in, the agent's copies of /// mounts etc.), the systemd resource-limits drop-in, the agent's copy of
/// its memory cap and stored permissions, and a daemon reload so all of it /// its memory cap, and a daemon reload so all of it takes effect on the next
/// takes effect on the next unit (re)start. Idempotent — the job queue's /// unit (re)start. Idempotent — the job queue's `WriteDropin` node, also
/// `WriteDropin` node, also folded into every `Swap` (rebuild is the /// folded into every `Swap` (rebuild is the reconcile verb).
/// reconcile verb).
pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> {
validate(name)?; validate(name)?;
let container = container_name(name); let container = container_name(name);
set_nspawn_flags(&container, &paths.agent, &paths.claude, &paths.notes).await?; set_nspawn_flags(&container, &paths.agent, &paths.claude, &paths.notes).await?;
hand_permissions(name, &paths.agent);
if let Some((cpu_quota, memory_max)) = stage_limits( if let Some((cpu_quota, memory_max)) = stage_limits(
name, name,
&crate::resource_limits::swarm_limits_path(), &crate::resource_limits::swarm_limits_path(),
@ -383,36 +381,22 @@ fn bind_own_agent_dirs(agent_name: &str, notes_dir: &Path, binds: &mut Vec<BindM
} }
} }
/// Whether `agent_name` holds `ManageRootAgent` per the swarm permissions /// Whether `agent_name` holds `ManageRootAgent` per the capabilities file
/// stored at `path`. An unreadable file denies: the error is logged and the /// at `caps_path`. An unreadable file denies: the error is logged and the
/// 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, caps_path: &Path) -> bool {
crate::permissions::holds(path, agent_name, Permission::ManageRootAgent).unwrap_or_else(|e| { crate::capabilities::has_cap(caps_path, agent_name, Capability::ManageRootAgent).unwrap_or_else(
tracing::error!( |e| {
agent = %agent_name, tracing::error!(
path = %path.display(), agent = %agent_name,
error = ?e, path = %caps_path.display(),
"swarm permissions unreadable — no ManageRootAgent mounts" error = ?e,
); "capabilities unreadable — no ManageRootAgent mounts"
false );
}) false
} },
)
/// Copy `agent`'s stored permissions into its runtime dir, which it holds
/// until its own first read of the swarm's key succeeds. A failure is logged
/// and the agent still starts, on the default.
pub fn hand_permissions(agent: &str, runtime_dir: &Path) {
let pending = crate::workers::permissions_import::pending();
if let Err(e) =
crate::permissions::hand_at(&crate::permissions::path(), pending, agent, runtime_dir)
{
tracing::error!(
agent = %agent,
error = ?e,
"could not hand the agent its stored permissions; it holds the default until it reads its key"
);
}
} }
/// Env var naming the host directory `swarm-bao-queue-agent.service` lands /// Env var naming the host directory `swarm-bao-queue-agent.service` lands
@ -585,25 +569,25 @@ async fn set_nspawn_flags(
read_only: true, read_only: true,
}); });
// `ManageRootAgent` permission: mount *every* agent in the hive as a // `ManageRootAgent` capability: 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 permission, not an // ⚠️ "every agent" is the definition of this capability, 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 permission // parentless, so that set *was* every agent anyway; the capability
// 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 permission now sees its own dirs and nothing else. // no capability now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::permissions::path()) { if holds_manage_root_agent(agent_name, &crate::capabilities::capabilities_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
@ -834,30 +818,22 @@ mod tests {
} }
#[test] #[test]
fn corrupt_swarm_permissions_deny_without_failing() { fn a_corrupt_capabilities_file_denies_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("capabilities.json");
let truncated = "{\n \"ruth\": {\"permissions\": [\"manage_root_ag"; let truncated = "{\n \"ruth\": [\"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());
} }
/// Unset is the default, which gives ruth the recovery mounts and nobody
/// else; a grant or an explicit empty list replaces it.
#[test] #[test]
fn the_swarm_grant_or_the_default_decides_the_recovery_mounts() { fn a_readable_grant_is_honoured() {
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("capabilities.json");
std::fs::write(&path, r#"{"ruth": ["manage_root_agent"]}"#).expect("seed");
assert!(holds_manage_root_agent("ruth", &path)); assert!(holds_manage_root_agent("ruth", &path));
assert!(!holds_manage_root_agent("alice", &path)); assert!(!holds_manage_root_agent("alice", &path));
std::fs::write(
&path,
r#"{"ruth": {"permissions": []}, "alice": {"permissions": ["manage_root_agent"]}}"#,
)
.expect("seed");
assert!(!holds_manage_root_agent("ruth", &path));
assert!(holds_manage_root_agent("alice", &path));
} }
const TRUNCATED_LIMITS: &str = "{\n \"sock\": { \"cpu_quota\": \"50%\", \"mem"; const TRUNCATED_LIMITS: &str = "{\n \"sock\": { \"cpu_quota\": \"50%\", \"mem";

View file

@ -11,7 +11,7 @@ pub use git::{
git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset, git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset,
git_rev_parse, git_tag, git_update_ref, git_update_ref_cas, git_rev_parse, git_tag, git_update_ref, git_update_ref_cas,
}; };
pub use host_config::{hand_permissions, write_dropins}; pub use host_config::write_dropins;
pub use setup::{ pub use setup::{
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix, ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
setup_applied, setup_proposed, setup_applied, setup_proposed,

View file

@ -43,7 +43,7 @@ mod swarm_status;
mod test_env; mod test_env;
mod workers; mod workers;
pub(crate) use agent_config::{limits, permissions, resource_limits, topology}; pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, 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};
@ -277,9 +277,6 @@ async fn cmd_serve(
if let Err(e) = migrate::run(&coord).await { if let Err(e) = migrate::run(&coord).await {
tracing::warn!(error = ?e, "startup migration failed"); tracing::warn!(error = ?e, "startup migration failed");
} }
// Before any agent starts, so the bind mounts it gets from the stored
// permissions are the meta repo's grants until the swarm answers.
crate::workers::permissions_import::seed_local();
// Auto-create the root agent container if it isn't there yet. Block // Auto-create the root agent container if it isn't there yet. Block
// on this — without root the system has no manager harness. // on this — without root the system has no manager harness.
// Failures are logged but allowed: a broken auto-spawn shouldn't // Failures are logged but allowed: a broken auto-spawn shouldn't

View file

@ -45,14 +45,26 @@ pub struct AgentSpec {
pub port: u16, pub port: u16,
} }
/// Stage topology.json if it exists: it is regenerated by `reconcile` /// Stage every generated meta JSON file that exists: topology.json is
/// whenever the agent set changed. Without staging, an /// regenerated by `reconcile` whenever the agent set changed;
/// tool-groups/capabilities are created lazily on
/// first write (`set_groups`/`set_caps`) — 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<()> {
if crate::topology::topology_path().exists() { for (path, name) in [
git(dir, &["add", "topology.json"]).await?; (crate::topology::topology_path(), "topology.json"),
(crate::tool_groups::tool_groups_path(), "tool-groups.json"),
(
crate::capabilities::capabilities_path(),
"capabilities.json",
),
] {
if path.exists() {
git(dir, &["add", name]).await?;
}
} }
Ok(()) Ok(())
} }
@ -72,7 +84,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();
@ -185,6 +197,8 @@ 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"),
"capabilities.json" => Some("capabilities"),
"tool-groups.json" => Some("tool-groups"),
_ => None, _ => None,
}) })
.collect(); .collect();
@ -290,35 +304,90 @@ 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
} }
/// Delete the meta repo's permission files and commit the removal under /// Write the tool-groups file for `agent` and commit it atomically
/// `META_LOCK`. Their grants live in the swarm's `agent-permissions` bucket /// under `META_LOCK`. Ensures the JSON change is staged + committed
/// once `workers::permissions_import` has sent them. /// 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(())
}
/// Write the capabilities file for `agent` and commit it atomically
/// under `META_LOCK`. Same rationale as `commit_tool_groups`.
pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> {
let _guard = META_LOCK.lock().await;
crate::capabilities::set_caps(agent, caps)
.map_err(|e| anyhow::anyhow!("set capabilities for {agent}: {e}"))?;
let dir = crate::paths::meta_root();
if crate::capabilities::capabilities_path().exists() {
git(&dir, &["add", "capabilities.json"]).await?;
}
if paths_dirty(&dir, &["capabilities.json"]).await? {
git_commit_paths(
&dir,
&format!("set capabilities for {agent}"),
&["capabilities.json"],
)
.await?;
}
Ok(())
}
/// Write both perm files for `agent` (whichever are `Some`) and commit
/// them in a SINGLE git commit under `META_LOCK` — the batch
/// `POST /api/permissions` path. A `None` field leaves that file
/// untouched. One commit + (caller does) one rebuild means changing an
/// agent's caps and tool-groups together no longer triggers two
/// rebuilds. Mirrors the staging discipline of `commit_tool_groups` /
/// `commit_capabilities`.
/// ///
/// # Errors /// # Errors
/// ///
/// Removing a file or a git stage/commit step fails. /// Returns an error if writing either JSON file fails, a capability name
pub async fn remove_permission_files() -> Result<()> { /// is invalid (`set_caps`), or a git stage/commit step fails.
pub async fn commit_perms(
agent: &str,
groups: Option<&[String]>,
caps: Option<&[String]>,
) -> Result<()> {
let _guard = META_LOCK.lock().await; let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root(); let dir = crate::paths::meta_root();
let mut removed = Vec::new(); let mut parts: Vec<&str> = Vec::new();
for file in crate::workers::permissions_import::META_FILES { if let Some(groups) = groups {
match std::fs::remove_file(dir.join(file)) { crate::tool_groups::set_groups(agent, groups)?;
Ok(()) => {} if crate::tool_groups::tool_groups_path().exists() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} git(&dir, &["add", "tool-groups.json"]).await?;
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);
} }
parts.push("tool-groups");
} }
if !removed.is_empty() { if let Some(caps) = caps {
crate::capabilities::set_caps(agent, caps)
.map_err(|e| anyhow::anyhow!("set capabilities for {agent}: {e}"))?;
if crate::capabilities::capabilities_path().exists() {
git(&dir, &["add", "capabilities.json"]).await?;
}
parts.push("capabilities");
}
let paths = ["tool-groups.json", "capabilities.json"];
if paths_dirty(&dir, &paths).await? {
git_commit_paths( git_commit_paths(
&dir, &dir,
"permissions moved to the swarm's agent-permissions", &format!("set {} for {agent}", parts.join(" + ")),
&removed, &paths,
) )
.await?; .await?;
} }
@ -327,7 +396,7 @@ pub async fn remove_permission_files() -> 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]) -> String { fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> {
render_flake( render_flake(
&hive.hyperhive_flake, &hive.hyperhive_flake,
&hive.hyperhive_docs_flake, &hive.hyperhive_docs_flake,
@ -354,7 +423,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],
) -> String { ) -> Result<String> {
render_flake_with_lookup( render_flake_with_lookup(
hyperhive_flake, hyperhive_flake,
docs_flake, docs_flake,
@ -757,7 +826,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,
) -> String ) -> Result<String>
where where
F: Fn(&str) -> Vec<&'static str>, F: Fn(&str) -> Vec<&'static str>,
{ {
@ -836,7 +905,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 }}:" " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null }}:"
); );
out.push_str( out.push_str(
r#" let r#" let
@ -845,6 +914,8 @@ 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; };
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
in in
base.extendModules { base.extendModules {
modules = [ modules = [
@ -1021,7 +1092,7 @@ where
} }
out.push_str( out.push_str(
r" }; r" };
systemd.services.${service}.environment = { systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // {
HIVE_PORT = toString port; HIVE_PORT = toString port;
HIVE_LABEL = name; HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort; HIVE_DASHBOARD_PORT = toString dashboardPort;
@ -1065,18 +1136,49 @@ 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()?;
let capabilities_map = crate::capabilities::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}\"")
};
// Emit `capabilities = "cap1,cap2"` when the operator has
// granted capabilities to this agent. Absent entry = null = no
// capability env var injected, capability-gated tools hidden.
let caps = capabilities_map
.get(&spec.name)
.cloned()
.unwrap_or_default();
let capabilities_attr = if caps.is_empty() {
"null".to_owned()
} else {
let joined = caps.join(",");
format!("\"{joined}\"")
};
let _ = writeln!( let _ = writeln!(
out, out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; }};", " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; }};",
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,
capabilities_attr,
); );
} }
out.push_str(" };\n };\n}\n"); out.push_str(" };\n };\n}\n");
out Ok(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
@ -1161,7 +1263,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 (limits, topology, lock bumps) goes through this; /// meta commit (perm files, 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<()> {
@ -1252,35 +1354,29 @@ 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("resource-limits.json"), "{}").expect("write"); std::fs::write(dir.join("tool-groups.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 limits change commits, path-limited. // …and a perm change commits, path-limited.
std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write"); std::fs::write(dir.join("tool-groups.json"), r#"{"alice":[]}"#).expect("write");
git(dir, &["add", "resource-limits.json"]) git(dir, &["add", "tool-groups.json"]).await.expect("add");
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
.await .await
.expect("add"); .expect("path-limited commit");
git_commit_paths( // The perm file is committed; the staged lock is not.
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, &["resource-limits.json"]) !paths_dirty(dir, &["tool-groups.json"])
.await .await
.expect("check"), .expect("check"),
"limits file must be committed" "perm 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 limits commit" "staged deploy lock must survive the perm commit"
); );
} }
@ -1317,7 +1413,8 @@ 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!(
@ -1363,7 +1460,8 @@ 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}"
@ -1381,7 +1479,8 @@ 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`, \
@ -1421,7 +1520,8 @@ 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\";"
@ -1449,7 +1549,8 @@ 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}"
@ -1469,7 +1570,8 @@ 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}"
@ -1504,7 +1606,8 @@ 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\""),
@ -1533,7 +1636,8 @@ 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
@ -1570,7 +1674,8 @@ 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");
} }
@ -1616,6 +1721,7 @@ 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");
@ -1795,7 +1901,8 @@ 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");
@ -1831,7 +1938,8 @@ 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()
@ -1876,6 +1984,7 @@ 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
@ -1968,6 +2077,7 @@ 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");
@ -2042,6 +2152,7 @@ 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");
@ -2073,8 +2184,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); let small = render_hive_flake(&hive("4G"), &agents).expect("render 4G");
let large = render_hive_flake(&hive("16G"), &agents); let large = render_hive_flake(&hive("16G"), &agents).expect("render 16G");
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

@ -10,11 +10,10 @@
//! `h-root`); both targeted layouts no live hive still has. Add one //! `h-root`); both targeted layouts no live hive still has. Add one
//! only if it cannot be expressed as convergence, and expect to delete //! only if it cannot be expressed as convergence, and expect to delete
//! it once every hive has passed it. //! it once every hive has passed it.
//! - **Create-time setup.** A thing that is true from birth is set where //! - **Create-time setup.** Ruth's tool groups were backfilled here on
//! the agent is created, not re-asserted each morning. Ruth's //! every boot; they are now seeded where she is created
//! permissions need neither: with no key in the swarm's //! (`workers::auto_update::ensure_root_agent`). A thing that is true
//! `agent-permissions` bucket she holds the code-constant default //! from birth does not need re-asserting each morning.
//! (`Permission::default_for`).
//! //!
//! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase //! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase
//! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`. //! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`.

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 permissions it /// ("ruth"), and ruth's reach comes entirely from the capabilities /
/// holds, identical to connecting on a per-agent socket — ruth /// tool-groups it 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

@ -98,7 +98,7 @@ pub fn spawn(
// reason, and is a third consumer rather than a second connection. // reason, and is a third consumer rather than a second connection.
tokio::spawn(crate::workers::wanted::watch_declarations( tokio::spawn(crate::workers::wanted::watch_declarations(
client.clone(), client.clone(),
std::sync::Arc::clone(&coord), coord,
hive.clone(), hive.clone(),
shutdown.clone(), shutdown.clone(),
)); ));
@ -109,14 +109,6 @@ pub fn spawn(
shutdown.clone(), shutdown.clone(),
)); ));
// The permissions the swarm grants, and the import of the meta repo's.
tokio::spawn(crate::workers::swarm_permissions::follow(
client.clone(),
std::sync::Arc::clone(&coord),
hive.clone(),
shutdown.clone(),
));
let mut health = SweepHealth::new("swarm_status_publish", "warn", FAILURES_BEFORE_BANNER); let mut health = SweepHealth::new("swarm_status_publish", "warn", FAILURES_BEFORE_BANNER);
loop { loop {
match publish(&client, &hive).await { match publish(&client, &hive).await {

View file

@ -23,6 +23,7 @@ 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/...
@ -123,6 +124,10 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
); );
return Ok(()); return Ok(());
} }
// Before the create/start branch, not inside it: on a hive whose root
// container already exists this is the only run that can still seed the
// grant, and it has to land before her next rebuild bakes the binds.
seed_manager_capabilities();
let list_result = lifecycle::list().await; let list_result = lifecycle::list().await;
if let Err(e) = &list_result { if let Err(e) = &list_result {
tracing::warn!( tracing::warn!(
@ -199,6 +204,7 @@ 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");
} }
@ -210,6 +216,109 @@ 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"
),
}
}
/// The capability the root agent is seeded with, as the `snake_case` string
/// `capabilities.json` stores and `capabilities::has_cap` compares against.
/// Taken from the enum rather than written out, so the seed cannot drift into
/// a name `capabilities::prune_unknown` would silently drop.
fn manager_seed_caps() -> Vec<String> {
vec![<&str>::from(hive_sh4re::permissions::Capability::ManageRootAgent).to_owned()]
}
/// Whether the root agent's default capability grant should be written.
///
/// `store_written` is "`capabilities.json` exists" — see
/// [`seed_manager_capabilities`] for why that, and not the absence of an
/// entry for the manager, is the condition.
fn should_seed_manager_caps(store_written: bool) -> bool {
!store_written
}
/// Give ruth the `ManageRootAgent` capability on the path that deploys her.
///
/// This is the capability-store half of the grant `roles.json` used to make:
/// `topology::reconcile_roles` seeded `can_manage_top_level_agents` onto
/// `MANAGER_NAME` on every meta sync, and that role is what put the other
/// agents' state/config dirs, `/applied` and `/meta` into her nspawn binds.
/// Collapsing the role into the capability means that without a seed here she
/// loses those recovery mounts at her next container rebuild — silently, and
/// only then, because nspawn bakes bind flags at container start.
///
/// **Seeded once, not re-ensured on every boot.** The role kept an empty-list
/// tombstone so an explicit revoke stuck; the capability store *deletes* an
/// entry that has been emptied rather than tombstoning it, so "the manager
/// has no entry" cannot tell a fresh hive apart from a deliberate revoke.
/// File existence can: every grant and revoke goes through
/// `meta::commit_capabilities` → `capabilities::set_caps`, which
/// writes the file even when the result is an empty `{}`. So while the file
/// is absent nobody has ever had a say, and once it exists this is inert
/// forever — including on the destroy+recreate path, matching
/// [`seed_manager_tool_groups`]'s refusal to reset an operator's choice.
///
/// Written with plain `set_caps` rather than `meta::commit_capabilities`: the
/// role's own seed wrote the file and left it for `stage_generated_meta_files`
/// to `git add` into the next deploy commit, and taking `META_LOCK` on the
/// boot path would be a new ordering constraint for no gain.
fn seed_manager_capabilities() {
if !should_seed_manager_caps(crate::capabilities::capabilities_path().exists()) {
tracing::debug!(
"capabilities.json already written — leaving the manager's capabilities as-is"
);
return;
}
let caps = manager_seed_caps();
match crate::capabilities::set_caps(MANAGER_NAME, &caps) {
Ok(()) => tracing::info!(
caps = ?caps,
"seeded ruth's default capabilities (recovery bind mounts for every agent)"
),
Err(e) => tracing::warn!(
error = ?e,
"failed to seed ruth's capabilities — she will come up without the recovery mounts"
),
}
}
/// 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
@ -463,7 +572,10 @@ fn submit_boot_tree(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{BootAction, MANAGER_NAME, RootAgentPlan, boot_action, plan_root_agent}; use super::{
BootAction, MANAGER_NAME, RootAgentPlan, boot_action, manager_seed_caps, plan_root_agent,
should_seed_manager_caps,
};
use crate::lifecycle::AGENT_PREFIX; use crate::lifecycle::AGENT_PREFIX;
use crate::power::Wanted; use crate::power::Wanted;
@ -492,6 +604,45 @@ mod tests {
assert_eq!(plan_root_agent(result), RootAgentPlan::Present); assert_eq!(plan_root_agent(result), RootAgentPlan::Present);
} }
// -----------------------------------------------------------------------
// Root-agent capability seed. `capabilities_path()` resolves under
// `paths::meta_root()`, which is hardcoded to `/var/lib/hyperhive` with no
// test override, so — same as `capabilities.rs`'s own tests — these pin
// the pure decision (`should_seed_manager_caps`, `manager_seed_caps`)
// rather than round-tripping the real file. Together they are the
// replacement for the deleted `reconcile_roles_in_seeds_root_when_absent`
// and `reconcile_roles_in_does_not_reseed_after_explicit_revoke`.
#[test]
fn the_manager_is_seeded_while_the_capability_store_has_never_been_written() {
assert!(should_seed_manager_caps(false));
assert_eq!(manager_seed_caps(), vec!["manage_root_agent".to_owned()]);
}
/// The revoke-sticks half, and the reason the condition is the file rather
/// than the manager's entry: the store deletes an emptied entry instead of
/// keeping a tombstone, but a revoke still writes the file — so once it
/// exists the seed must never fire again.
#[test]
fn a_written_capability_store_is_never_reseeded() {
assert!(!should_seed_manager_caps(true));
}
/// A seed the store would drop as unrecognised grants nothing while
/// logging success, so pin that what we write is a name `prune_unknown`
/// keeps — i.e. one of `Capability::ALL`'s `snake_case` spellings.
#[test]
fn the_seeded_name_is_a_capability_the_store_recognises() {
for cap in manager_seed_caps() {
assert!(
hive_sh4re::permissions::Capability::ALL
.iter()
.any(|known| <&str>::from(*known) == cap),
"{cap} is not a known capability"
);
}
}
// The regression the `Unreadable` variant exists for: the arm used to // The regression the `Unreadable` variant exists for: the arm used to
// substitute `from_running(running)`, which is `Noop` against BOTH // substitute `from_running(running)`, which is `Noop` against BOTH
// observations — so no combination of inputs could reach a reconcile. // observations — so no combination of inputs could reach a reconcile.

View file

@ -1,7 +1,7 @@
//! Background tasks and periodic sweeps: crash/login watcher, boot-time //! Background tasks and periodic sweeps: crash/login watcher, boot-time
//! auto-update reconcile, the agent-sockets.json writer loop, the MCP socket listener //! auto-update reconcile, the agent-sockets.json writer loop, the MCP socket listener
//! reconcile loop, knowledge-repo sync, the swarm wanted-state pull, and the //! reconcile loop, knowledge-repo sync, the swarm wanted-state pull, and the
//! swarm resource-cap and permissions reads. //! swarm resource-cap read.
//! Each submodule is re-exported //! Each submodule is re-exported
//! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged. //! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged.
@ -10,7 +10,5 @@ pub mod auto_update;
pub mod crash_watch; pub mod crash_watch;
pub mod knowledge; pub mod knowledge;
pub mod mcp_sockets; pub mod mcp_sockets;
pub mod permissions_import;
pub mod swarm_limits; pub mod swarm_limits;
pub mod swarm_permissions;
pub mod wanted; pub mod wanted;

View file

@ -1,338 +0,0 @@
//! The one-shot move of the permissions this hive's meta repo held
//! (`meta/capabilities.json` and `meta/tool-groups.json`) into the swarm's
//! `agent-permissions` bucket, as one list per agent.
//!
//! At boot, before any agent starts, [`seed_local`] copies the files' grants
//! into [`crate::permissions`] when nothing is stored there yet, so agents
//! keep their mounts until the swarm answers. [`send_pending`] then sends the
//! grants to swarm-controller, which writes each agent's key only where there
//! is none, and on its answer deletes both files from the meta repo. Nothing
//! reads them after that.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
};
const CAPABILITIES_FILE: &str = "capabilities.json";
const TOOL_GROUPS_FILE: &str = "tool-groups.json";
/// The meta repo files the grants are moved out of.
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 one list per agent the two files add up to, as the swarm stores it.
/// `None` for either file means it does not exist.
///
/// Each half is what the agent held under the old readers:
/// - tool groups: the entry's known names; an absent entry, or one naming
/// only unknown names, was the default groups. `lifecycle`/`forge` count as
/// known (they parsed) and are then dropped, so an entry of only those
/// held no tools and imports as none.
/// - capabilities: the entry's names. With the file present, `ruth` having no
/// entry means her grant was revoked: the file was created holding it, and
/// a revoke removed the entry. With no file, the default.
///
/// An agent whose list equals [`Permission::default_for`] is left out, so its
/// key stays unset and follows the default.
fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants {
let mut agents: BTreeSet<&str> = BTreeSet::new();
agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str)));
agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str)));
if caps.is_some() {
agents.insert(crate::lifecycle::MANAGER_NAME);
}
let mut out = Grants::new();
for agent in agents {
let default = Permission::default_for(agent);
let tool_default: Vec<Permission> = default
.iter()
.copied()
.filter(|p| *p != Permission::ManageRootAgent)
.collect();
let tool_part = match groups.and_then(|m| m.get(agent)) {
Some(names)
if names.iter().any(|n| {
parse(agent, n).is_some()
|| EMPTY_TOOL_GROUPS.iter().any(|g| n.eq_ignore_ascii_case(g))
}) =>
{
names.iter().filter_map(|n| parse(agent, n)).collect()
}
_ => tool_default,
};
let cap_part: Vec<Permission> = match caps {
Some(m) => m
.get(agent)
.map(|names| names.iter().filter_map(|n| parse(agent, n)).collect())
.unwrap_or_default(),
None => default
.iter()
.copied()
.filter(|p| *p == Permission::ManageRootAgent)
.collect(),
};
let mut list: Vec<Permission> = Vec::new();
for perm in tool_part.into_iter().chain(cap_part) {
if !list.contains(&perm) {
list.push(perm);
}
}
let same_as_default =
list.len() == default.len() && default.iter().all(|p| list.contains(p));
if !same_as_default {
out.insert(agent.to_owned(), list);
}
}
out
}
fn read_file(path: &Path) -> Result<Option<FileMap>> {
if !path.exists() {
return Ok(None);
}
let map = serde_json::from_slice(
&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(Some(map))
}
/// The grants the files in `meta` hold, or `None` when neither exists.
fn read_grants(meta: &Path) -> Result<Option<Grants>> {
let caps = read_file(&meta.join(CAPABILITIES_FILE))?;
let groups = read_file(&meta.join(TOOL_GROUPS_FILE))?;
if caps.is_none() && groups.is_none() {
return Ok(None);
}
Ok(Some(grants(caps.as_ref(), groups.as_ref())))
}
fn meta_dir() -> PathBuf {
crate::paths::meta_root()
}
/// Whether a meta repo file is still waiting to be imported.
#[must_use]
pub fn pending() -> bool {
let meta = meta_dir();
META_FILES.iter().any(|f| meta.join(f).exists())
}
/// Store the files' grants as the last-read swarm permissions, when a file
/// is still there and nothing has been stored yet.
pub fn seed_local() {
if let Err(e) = seed_local_at(&meta_dir(), &crate::permissions::path()) {
tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed");
}
}
fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> {
if stored.exists() {
return Ok(());
}
let Some(grants) = read_grants(meta)? else {
return Ok(());
};
let records = grants
.into_iter()
.map(|(agent, perms)| {
(
agent,
AgentPermissions {
permissions: Some(perms),
},
)
})
.collect();
crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo");
Ok(())
}
/// What one attempt to send the import came to.
#[derive(Debug)]
pub enum ImportOutcome {
/// No meta file is left: nothing to import.
NothingPending,
/// The controller took the grants and the files are deleted.
Imported,
/// The controller answered with a refusal; the files stay.
Refused(String),
/// No answer (the controller or the queue is down), or a file could not
/// be read or deleted; the files stay.
Unsettled(anyhow::Error),
}
impl ImportOutcome {
/// Whether the swarm's keys may be read after this outcome. While a file
/// is left, an unset key may only mean its grants are not imported yet,
/// and reading it as the default would undo them.
#[must_use]
pub fn read_allowed(&self) -> bool {
matches!(self, Self::NothingPending | Self::Imported)
}
/// Why reading is not allowed, for the caller's log and banner.
#[must_use]
pub fn into_error(self) -> Option<anyhow::Error> {
match self {
Self::NothingPending | Self::Imported => None,
Self::Refused(why) => Some(anyhow::anyhow!("the controller refused the import: {why}")),
Self::Unsettled(e) => Some(e.context("importing the meta repo's permissions")),
}
}
}
/// Send the files' grants to swarm-controller while a file is there, and
/// delete both once the controller has answered that it handled them. The
/// next pass sends again after anything but [`ImportOutcome::Imported`]; the
/// controller keeps a key it already has.
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> ImportOutcome {
match send_at(client, hive, &meta_dir()).await {
Ok(outcome) => outcome,
Err(e) => ImportOutcome::Unsettled(e),
}
}
async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result<ImportOutcome> {
let Some(permissions) = read_grants(meta)? else {
return Ok(ImportOutcome::NothingPending);
};
swarm_queue_client::ensure_connected(client)?;
let payload = serde_json::to_vec(&Import { permissions })?;
let reply = tokio::time::timeout(
IMPORT_REPLY_DEADLINE,
client.request(agent_permissions::import_subject(hive), payload.into()),
)
.await
.context("the controller did not answer the import")?
.context("sending the import")?;
match serde_json::from_slice(&reply.payload).context("decoding the controller's answer")? {
ImportReply::Imported {
written,
kept,
skipped,
} => {
tracing::info!(
?written,
?kept,
?skipped,
"import: the controller took the meta repo's permissions"
);
crate::meta::remove_permission_files()
.await
.context("deleting the imported permission files")?;
Ok(ImportOutcome::Imported)
}
ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)),
}
}
#[cfg(test)]
mod tests {
use super::{FileMap, grants, seed_local_at};
use hive_sh4re::permissions::Permission::{self, *};
fn file(json: &str) -> FileMap {
serde_json::from_str(json).expect("decodes")
}
/// Both files add up per agent; the empty groups and unknown names drop;
/// an agent left on the default is not sent.
#[test]
fn the_two_files_merge_into_one_list() {
let caps = file(r#"{"ruth": ["manage_root_agent"], "atlas": ["manage_root_agent"]}"#);
let groups = file(
r#"{"ruth": ["messaging", "meta", "inbox", "lifecycle", "execution"],
"atlas": ["messaging", "web_tools", "forge"],
"iris": ["messaging", "fly_to_the_moon"],
"sock": ["lifecycle"]}"#,
);
let out = grants(Some(&caps), Some(&groups));
assert!(!out.contains_key("ruth"), "ruth's seed is her default");
assert_eq!(out["atlas"], vec![Messaging, WebTools, ManageRootAgent]);
assert_eq!(out["iris"], vec![Messaging]);
assert_eq!(out["sock"], Vec::<Permission>::new());
}
/// Without a capabilities entry, ruth's existing file means a revoke; with
/// no capabilities file at all, her default stands.
#[test]
fn a_revoked_ruth_stays_revoked() {
let out = grants(Some(&file("{}")), None);
assert_eq!(out["ruth"], vec![Messaging, Meta, Inbox, Execution]);
let out = grants(None, Some(&file(r#"{"atlas": ["messaging"]}"#)));
assert!(!out.contains_key("ruth"));
assert_eq!(out["atlas"], vec![Messaging]);
}
/// The seed writes once, from the files, and never over a stored read.
#[test]
fn the_seed_copies_the_files_only_when_nothing_is_stored() {
let dir = tempfile::tempdir().expect("tempdir");
let meta = dir.path();
let stored = dir.path().join("stored.json");
seed_local_at(meta, &stored).expect("no file is no seed");
assert!(!stored.exists());
std::fs::write(
meta.join("capabilities.json"),
r#"{"atlas": ["manage_root_agent"]}"#,
)
.expect("seed");
seed_local_at(meta, &stored).expect("seeded");
assert!(crate::permissions::holds(&stored, "atlas", ManageRootAgent).expect("read"));
assert!(!crate::permissions::holds(&stored, "ruth", ManageRootAgent).expect("read"));
std::fs::write(&stored, "{}").expect("a later read");
seed_local_at(meta, &stored).expect("left alone");
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,317 +0,0 @@
//! Reading the permissions the swarm grants this hive's agents.
//!
//! swarm-controller keeps one key per agent in the `agent-permissions` bucket
//! ([`swarm_queue_client::agent_permissions`]). Every [`READ_INTERVAL`] this
//! reads the key of each agent this hive has and stores what it finds with
//! [`crate::permissions`], where the container's bind mounts are worked out.
//! Those are fixed when the container starts, so an agent whose
//! `manage_root_agent` changed is restarted, after its current turn. The
//! agent applies the rest of its permissions itself; after every pass each
//! agent's copy of its stored entry is refreshed
//! ([`crate::permissions::hand_at`]), which it holds until it reads its key.
//!
//! A failed read, or a bucket that does not answer, leaves the stored
//! permissions as they are and restarts nothing.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read};
use crate::coordinator::Coordinator;
use crate::permissions::{self, Stored};
use crate::stats::sweep_health::{self, SweepHealth};
/// How often the permissions are read.
pub const READ_INTERVAL: Duration = Duration::from_secs(30);
/// At [`READ_INTERVAL`] this is ~90 seconds of failing reads.
const FAILURES_BEFORE_BANNER: u32 = 3;
/// Send the meta repo's grants once, then read the permissions now and every
/// [`READ_INTERVAL`] until shutdown.
pub async fn follow(
client: async_nats::Client,
coord: Arc<Coordinator>,
hive: String,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let mut health = SweepHealth::new("swarm_permissions_read", "warn", FAILURES_BEFORE_BANNER);
loop {
let import = super::permissions_import::send_pending(&client, &hive).await;
let pass = if import.read_allowed() {
sync(&client).await
} else {
Err(import
.into_error()
.unwrap_or_else(|| anyhow::anyhow!("import unsettled")))
};
match pass {
Ok(changed) => {
health.record_ok();
restart(&coord, changed).await;
}
Err(e) => {
tracing::warn!(error = ?e, "swarm permissions: the pass failed");
let err = format!("{e:#}");
health.record_err(|ctx| {
let age = ctx.since_last_ok.map_or_else(
|| "no success this session".to_owned(),
|d| format!("last ok {} ago", sweep_health::fmt_age(d)),
);
format!(
"syncing the swarm's agent permissions is failing ({} consecutive, \
{age}) — agents keep the permissions last read: {err}",
ctx.consecutive
)
});
}
}
hand_all().await;
tokio::select! {
() = tokio::time::sleep(READ_INTERVAL) => {}
_ = shutdown.changed() => {
tracing::info!("swarm permissions: shutdown signal received");
return;
}
}
}
}
/// Read every agent's key and store the result. Returns the agents whose
/// `manage_root_agent` changed.
async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client)?;
let agents: Vec<String> = crate::lifecycle::agents_for_meta_listing()
.await
.context("enumerating this hive's agents")?
.into_iter()
.map(|spec| spec.name)
.collect();
sync_with(&agents, &permissions::path(), |agent| async move {
agent_permissions::read(client, &agent)
.await
.map_err(|e| anyhow::anyhow!(e))
.with_context(|| format!("reading the swarm's permissions for {agent}"))
})
.await
}
/// [`sync`] with the read and the file given. Nothing is written unless every
/// read succeeds.
async fn sync_with<F, Fut>(agents: &[String], path: &Path, read: F) -> Result<Vec<String>>
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<Read>>,
{
let mut read_now = Stored::new();
for agent in agents {
match read(agent.clone()).await? {
Read::Current(record) => {
if record != AgentPermissions::default() {
read_now.insert(agent.clone(), record);
}
}
Read::NoBucket => {
tracing::debug!("swarm permissions: the agent-permissions bucket does not answer");
return Ok(Vec::new());
}
}
}
let stored = permissions::read_at(path).context("reading the stored swarm permissions")?;
if read_now == stored {
return Ok(Vec::new());
}
permissions::write_at(path, &read_now).context("storing the swarm permissions")?;
tracing::info!(permissions = ?read_now, "swarm permissions: stored the swarm's permissions");
Ok(agents
.iter()
.filter(|a| manages(a, &stored) != manages(a, &read_now))
.cloned()
.collect())
}
/// Refresh the copy of its stored permissions each agent with a runtime dir
/// holds, so it follows this pass's reads and the import's progress.
async fn hand_all() {
let agents = match crate::lifecycle::agents_for_meta_listing().await {
Ok(agents) => agents,
Err(e) => {
tracing::warn!(error = ?e, "swarm permissions: enumerating agents to hand their copies failed");
return;
}
};
for spec in agents {
let dir = crate::paths::agent_runtime_dir(&spec.name);
if dir.is_dir() {
crate::lifecycle::hand_permissions(&spec.name, &dir);
}
}
}
/// Whether `agent` holds the one permission hive-c0re enforces under `stored`.
fn manages(agent: &str, stored: &Stored) -> bool {
permissions::effective(agent, stored.get(agent)).contains(&Permission::ManageRootAgent)
}
/// Restart each running agent in `changed` once its current turn is done, so
/// its container starts with the bind mounts its new permissions give. A
/// stopped agent gets them at its next start.
async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
let mut running = Vec::new();
for agent in changed {
if crate::lifecycle::is_running(&agent).await {
running.push(agent);
}
}
if running.is_empty() {
return;
}
tracing::info!(agents = ?running, "swarm permissions: manage_root_agent changed, restarting");
if let Err(e) = crate::job_queue::power::restart_many(coord, &running, true).await {
tracing::error!(agents = ?running, error = ?e, "swarm permissions: restart insert failed");
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Read, sync_with};
use swarm_queue_client::agent_permissions::AgentPermissions;
fn record(json: &str) -> AgentPermissions {
serde_json::from_str(json).expect("decodes")
}
fn agents() -> Vec<String> {
vec!["atlas".to_owned(), "ruth".to_owned()]
}
/// Sync `agents()` against `path` with each agent read as `answer` gives.
async fn sync(
path: &Path,
answer: impl Fn(&str) -> anyhow::Result<Read>,
) -> anyhow::Result<Vec<String>> {
sync_with(&agents(), path, |agent| {
let out = answer(&agent);
async move { out }
})
.await
}
/// No key anywhere is everyone on the default: nothing stored, and no
/// restart, since that is what the agents already run with.
#[tokio::test]
async fn missing_keys_are_the_default_and_restart_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |_| Ok(Read::Current(AgentPermissions::default())))
.await
.expect("read");
assert!(changed.is_empty());
assert!(crate::permissions::read_at(&path).expect("read").is_empty());
assert!(
crate::permissions::holds(
&path,
"ruth",
hive_sh4re::permissions::Permission::ManageRootAgent
)
.expect("read")
);
}
/// Revoking ruth's default, and granting atlas, restarts both; clearing
/// them again restarts both back.
#[tokio::test]
async fn a_changed_manage_root_agent_asks_for_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"permissions":[]}"#
} else {
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
.expect("read");
assert_eq!(changed, agents());
// The same answer again changes nothing.
let unchanged = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"permissions":[]}"#
} else {
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
.expect("read");
assert!(unchanged.is_empty());
let cleared = sync(&path, |_| Ok(Read::Current(AgentPermissions::default())))
.await
.expect("read");
assert_eq!(cleared, agents());
}
/// A change that leaves `manage_root_agent` where it was is stored and
/// restarts nothing: the agent applies tool permissions itself.
#[tokio::test]
async fn a_tool_only_change_is_stored_without_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(if agent == "ruth" {
record(r#"{"permissions":["manage_root_agent"]}"#)
} else {
record(r#"{"permissions":["messaging","web_tools"]}"#)
}))
})
.await
.expect("read");
assert!(changed.is_empty());
assert!(
crate::permissions::read_at(&path)
.expect("read")
.contains_key("atlas")
);
}
/// One failed read, or a bucket that does not answer, keeps what was
/// stored and restarts nothing.
#[tokio::test]
async fn a_failed_read_keeps_the_last_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let revoked = || Ok(Read::Current(record(r#"{"permissions":[]}"#)));
// atlas holds no manage_root_agent either way, so only ruth changes.
assert_eq!(
sync(&path, |_| revoked()).await.expect("read"),
vec!["ruth".to_owned()]
);
let before = std::fs::read(&path).expect("stored");
sync(&path, |agent| {
if agent == "ruth" {
Err(anyhow::anyhow!("timed out"))
} else {
Ok(Read::Current(AgentPermissions::default()))
}
})
.await
.expect_err("a failed read fails the pass");
assert_eq!(std::fs::read(&path).expect("stored"), before);
let changed = sync(&path, |_| Ok(Read::NoBucket))
.await
.expect("no bucket");
assert!(changed.is_empty());
assert_eq!(std::fs::read(&path).expect("stored"), before);
}
}

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 (`State`, `NodeId`) is protocol-local. The typed needs (`Source`, `State`, `PermPayload`, `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,5 +1,6 @@
//! Vocabulary hive-c0re's job queue shares with its clients: the //! Vocabulary hive-c0re's job queue shares with its clients: what a
//! scheduler's lifecycle [`State`]. //! permission change carries ([`PermPayload`]) and the scheduler's
//! 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
@ -14,8 +15,27 @@
//! 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> },
/// Set the capabilities for one agent (`capabilities.json`).
Capabilities { caps: Vec<String> },
/// Set both perm-types in one entry — the batch
/// `POST /api/permissions` path. `None` leaves that file untouched;
/// present fields commit together and rebuild once.
Combined {
groups: Option<Vec<String>>,
caps: Option<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,3 +16,7 @@ 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,32 +1,37 @@
//! Per-agent authorization: the one [`Permission`] set an agent holds. //! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and
//! system-level capability grants (`Capability`). Both are declared in
//! per-agent config (`tool-groups.json` / `capabilities.json`), injected
//! into the container as env vars, and read by the harness to decide
//! which MCP tools claude actually sees.
//! //!
//! The set lives in the swarm's `agent-permissions` record //! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]):
//! (`swarm_queue_client::agent_permissions`), one list per agent. Each member //! that set is group-gated too (`web_tools`), so it belongs next to the
//! is enforced where it applies: the MCP-tool and built-in-tool members by //! groups rather than in any one consumer. Both processes that spawn a
//! the processes that spawn `claude` (the agent harness and the subagent //! `claude` — the harness itself and the subagent daemon — call the same
//! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An //! function, so a subagent can never be handed a built-in its parent does
//! agent the swarm sets nothing for holds [`Permission::default_for`]. //! not have — with one deliberate exception: [`subagent_builtin_tools_arg`]
//! //! additionally grants a **subagent** Claude's own `Bash` when the parent
//! It also resolves the *built-in* claude tool list ([`builtin_tools_for`]): //! holds [`ToolGroup::Execution`]. The parent never gets built-in `Bash`
//! that set is permission-gated too (`web_tools`), so it belongs here rather //! itself (it has the out-of-process `bash` MCP server instead); the
//! than in any one consumer. Both processes that spawn a `claude` call the //! capability transfers to a subagent, not the mechanism.
//! same function, so a subagent can never be handed a built-in its parent
//! does not have — with one deliberate exception: use std::str::FromStr;
//! [`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};
/// Built-in claude tools present in every session regardless of permissions. /// Env var the meta renderer writes with a comma-separated list of
/// Anything not in this list (and not added by a permission's /// [`ToolGroup`] `snake_case` names (e.g. `"messaging,inbox,meta"`). Absent
/// [`Permission::builtin_tools`]) literally doesn't exist in the session — /// or unparseable means [`ToolGroup::AGENT_DEFAULT`]. See
/// `docs/process/conventions.md::Tool groups`.
pub const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
/// Built-in claude tools present in every session regardless of tool
/// groups. Anything not in this list (and not added by a group's
/// [`ToolGroup::builtin_tools`]) literally doesn't exist in the session —
/// 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 permission-gated (`web_tools`) and so /// Web egress (`WebFetch`/`WebSearch`) is group-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
@ -40,24 +45,23 @@ use serde::{Deserialize, Serialize};
/// 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 built-in claude tools `perms` resolve to: [`ALLOWED_BUILTIN_TOOLS`] /// The tool groups in effect for this process, from [`TOOL_GROUPS_ENV`].
/// plus each permission's own built-ins, de-duplicated, base set first.
/// ///
/// ⚠️ The result is a claude `--tools` value once joined, and must **never be /// Absent, blank, or naming nothing recognisable all fall back to
/// empty** — not because of what an empty value does, but because we don't /// [`ToolGroup::AGENT_DEFAULT`] — the var is written per agent by the meta
/// know. Our own measurement and the installed `claude --help` disagree about /// renderer, so "unset" is an ordinary state, not an error.
/// 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 builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> { pub fn effective_tool_groups() -> Vec<ToolGroup> {
ToolGroup::parse_list(&std::env::var(TOOL_GROUPS_ENV).unwrap_or_default())
}
/// The built-in claude tools `groups` resolve to: [`ALLOWED_BUILTIN_TOOLS`]
/// plus each group's own built-ins, de-duplicated, base set first.
#[must_use]
pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec(); let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec();
for perm in perms { for group in groups {
for tool in perm.builtin_tools() { for tool in group.builtin_tools() {
if !tools.contains(tool) { if !tools.contains(tool) {
tools.push(tool); tools.push(tool);
} }
@ -66,131 +70,205 @@ pub fn builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> {
tools tools
} }
/// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `perms` /// The value for claude's `--tools` flag: which built-in tools exist in this
/// contains [`Permission::Execution`] — the **subagent**-only `--tools` /// session at all.
///
/// ⚠️ **Never emit this as an empty string** — not because of what an empty
/// value does, but because we don't know. Our own measurement and the
/// installed `claude --help` disagree about whether `--tools ""` means "no
/// tools" or parses as the flag being unset, and the answer is a property of
/// whichever claude release is installed, not of this code. So an empty
/// value is refused rather than relied on: the rule holds under either
/// reading, and no caller has to know which one is true today.
/// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case from
/// arising in the first place.
///
/// ⚠️ This governs built-ins only. `mcp__*` tools are not filtered by
/// `--tools` at all; they are governed by `--strict-mcp-config` plus
/// whatever the caller renders into `--mcp-config`.
#[must_use]
pub fn builtin_tools_arg() -> String {
builtin_tools_for(&effective_tool_groups()).join(",")
}
/// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `groups`
/// contains [`ToolGroup::Execution`] — the **subagent**-only `--tools`
/// 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 permission through; it gets Claude's own `Bash` /// it to inherit that capability through; it gets Claude's own `Bash`
/// instead. /// instead. The capability transfers from the parent, the mechanism it
/// arrives by does not have to match.
/// ///
/// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`Permission::builtin_tools`]: /// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`ToolGroup::builtin_tools`]:
/// those are shared with the harness's own `--tools`, and the main agent must /// those are shared with the harness's own `--tools` (`hive-agent`'s
/// never gain built-in `Bash` no matter which permissions it holds — only a /// `mcp_config::allowed_tools_arg`), and the main agent must never gain
/// *subagent* spawned by an `Execution`-holding parent does. /// built-in `Bash` no matter which groups it holds — only a *subagent*
/// spawned by an `Execution`-holding parent does.
#[must_use] #[must_use]
pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> { pub fn subagent_builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
let mut tools = builtin_tools_for(perms); let mut tools = builtin_tools_for(groups);
if perms.contains(&Permission::Execution) { if groups.contains(&ToolGroup::Execution) {
tools.push("Bash"); tools.push("Bash");
} }
tools tools
} }
/// One thing an agent may be permitted. Stored by its `snake_case` name. /// The value for a **subagent's** `--tools` flag: [`subagent_builtin_tools_for`]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] /// resolved against [`effective_tool_groups`]. See that function for why this
/// differs from [`builtin_tools_arg`].
#[must_use]
pub fn subagent_builtin_tools_arg() -> String {
subagent_builtin_tools_for(&effective_tool_groups()).join(",")
}
/// Named group of MCP tools an agent may be granted. The harness reads
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
/// `snake_case` group names written by the meta renderer from per-agent
/// config) and expands it to the matching tool names for `--allowedTools`.
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
/// See `docs/process/conventions.md::Tool groups`.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::IntoStaticStr,
strum::EnumString,
)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")] #[strum(serialize_all = "snake_case")]
pub enum Permission { pub enum ToolGroup {
/// `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/capabilities.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/capabilities.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_permission` in `hive-agent/src/mcp_config.rs`. /// `extra_server_required_group` 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` /// `WebSearch` (search the web). Both are omitted from `--tools` by
/// without it; holding it enables them in the session and in /// default; adding this group to an agent enables them in the session
/// `--allowedTools` so they run without a confirmation prompt. /// and in `--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 Permission { impl ToolGroup {
/// Every permission in a stable order. /// Parse a comma-separated [`TOOL_GROUPS_ENV`] value. Tokens are trimmed
pub const ALL: &'static [Self] = &[ /// and lowercased; an unrecognised one is warned about and skipped
Self::Messaging, /// rather than failing the whole list, so one stale name in an agent's
Self::Meta, /// config can't take its whole tool surface away.
Self::Inbox, ///
Self::Execution, /// Returns [`AGENT_DEFAULT`](Self::AGENT_DEFAULT) when `raw` is blank or
Self::WebTools, /// names nothing recognisable — the same fallback either way, because
Self::ManageRootAgent, /// "the operator granted no groups" and "the var is missing" are not
]; /// 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 default_for(agent: &str) -> Vec<Self> { pub fn parse_list(raw: &str) -> Vec<Self> {
let mut perms = Self::DEFAULT.to_vec(); let mut groups = Vec::new();
if agent == crate::manager::MANAGER_AGENT { for token in raw.split(',') {
perms.push(Self::ManageRootAgent); let token = token.trim().to_ascii_lowercase();
if token.is_empty() {
continue;
}
match Self::from_str(&token) {
Ok(group) => groups.push(group),
Err(_) => {
tracing::warn!(token = %token, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
}
}
} }
perms if groups.is_empty() {
if !raw.trim().is_empty() {
tracing::warn!(
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
falling back to AGENT_DEFAULT"
);
}
return Self::AGENT_DEFAULT.to_vec();
}
groups
} }
/// The MCP tool names (without the `mcp__hyperhive__` prefix) this /// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
/// permission grants. Empty for the permissions that grant something /// Returns `&[]` for `WebTools` (it enables Claude built-in tools, not MCP
/// else; see each variant's doc comment. /// tools — see `builtin_tools()`) and for `Lifecycle` / `Forge` (their
/// 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"],
// `Execution` grants the out-of-process `bash` MCP server, // All four empty, for different reasons — see each
// gated at config-render time in `hive-agent/src/mcp_config.rs` // variant's own doc comment above. `Execution` grants the
// — an out-of-process server has no later enforcement point, so // out-of-process `bash` MCP server
// that gate is the actual security boundary. `WebTools` grants // (`mcp__bash__run`/`status`/`kill`), gated at config-render
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants // time by `extra_server_required_group` in
// bind mounts. // `hive-agent/src/mcp_config.rs`, not by this list — an
Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], // out-of-process server has no later enforcement point, so
// that gate is the actual security boundary. `WebTools`
// grants Claude built-in tools, not MCP ones; see
// `builtin_tools()`. `Lifecycle` and `Forge` each listed
// exactly one tool — `list_containers` and `create_repo`
// respectively — and both tools were removed outright; the
// variants stay so existing grants parse.
Self::Lifecycle | Self::Forge | Self::Execution | Self::WebTools => &[],
} }
} }
/// MCP tools that are always exposed regardless of which permissions an /// MCP tools that are always exposed regardless of which tool groups an
/// agent holds. `set_status` lives here because the operator /// agent is granted. `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 would let a misconfigured agent go dark on the /// chip — gating it behind a group would let a misconfigured agent go
/// dashboard. The server-side `SetStatus` handler has no permission /// dark on the dashboard. The server-side `SetStatus` handler has no
/// check either (only length validation), so listing it here keeps the /// tool-group check either (only length validation), so listing it here
/// `--allowedTools` list honest with that reality. /// keeps the `--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, and every /// effect, no privilege), gated server-side on context usage rather
/// agent should be able to reach for it — same reasoning as `set_status`. /// than on tool groups, and every agent should be able to reach for it
/// regardless of which optional groups it's been granted — same
/// reasoning as `set_status`.
/// ///
/// `mark_todos_done` too: todos are pushed to an agent whether or not it /// `mark_todos_done` too (a critical bug every agent hit): it was
/// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/ /// declared as a `#[tool]` fn but never added to *any*
/// `remind`), so an agent without `Inbox` must still be able to clear /// group's [`tools`](Self::tools), including `Inbox`, so no agent could
/// them. /// ever get it into `--allowedTools` and every call prompted for
/// approval it can't get. Todos are pushed to an agent independent of
/// whether it holds `Inbox` (that group only gates
/// `get_loose_ends`/`cancel_loose_end`/`remind`), so an agent without
/// `Inbox` could accumulate todos it can never clear — same
/// "every agent needs this regardless of optional groups" shape as
/// `set_status`/`compact`, not a narrower `Inbox`-only fix.
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 this permission enables. Only /// The Claude built-in tool names enabled by this group. Only
/// `WebTools` returns a non-empty slice. /// `WebTools` returns a non-empty slice; all other groups return `&[]`
/// (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 {
@ -198,91 +276,179 @@ impl Permission {
_ => &[], _ => &[],
} }
} }
/// 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. Stored in `meta/capabilities.json`
/// (same shape as `tool-groups.json`: `{ "alice": ["manage_root_agent"] }`).
/// Capabilities control system-level access hive-c0re enforces at
/// dispatch time; they are orthogonal to tool groups (which control
/// which MCP tools the harness exposes to claude).
///
/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated
/// `snake_case`) via `meta::render_flake`. The harness reads this to
/// conditionally register capability-gated MCP tools so claude only
/// sees tools it can actually invoke. 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 `capabilities.json`
/// into an unrecognised name that `capabilities::prune_unknown`
/// silently drops.
ManageRootAgent,
}
impl Capability {
/// Every known capability in a stable order. Use this to enumerate
/// columns in the permissions UI or validate incoming capability strings.
pub const ALL: &'static [Self] = &[Self::ManageRootAgent];
/// Short human-readable description suitable for a tooltip or help text.
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::ManageRootAgent => {
"manage any agent: every agent's state (rw) and config (ro) mounted for recovery"
}
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// The stored names are the `snake_case` spellings the old tool-group /// The env-var spellings the meta renderer actually writes must round-trip
/// and capability files used, so an imported grant decodes. /// back to groups. `strum::EnumString` and serde's `rename_all` are two
/// independent derives over the same names; this pins them together, since
/// the renderer writes what serde would produce and the parse is strum's.
#[test] #[test]
fn stored_names_are_snake_case() { fn parse_list_accepts_the_snake_case_names_the_renderer_writes() {
let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect();
assert_eq!( assert_eq!(
names, ToolGroup::parse_list("messaging, Meta ,web_tools"),
[ vec![ToolGroup::Messaging, ToolGroup::Meta, ToolGroup::WebTools]
"messaging",
"meta",
"inbox",
"execution",
"web_tools",
"manage_root_agent"
]
); );
for perm in Permission::ALL { for group in ToolGroup::ALL {
let json = serde_json::to_string(perm).unwrap(); let name: &'static str = (*group).into();
assert_eq!(json, format!("\"{}\"", <&str>::from(*perm))); assert_eq!(ToolGroup::parse_list(name), vec![*group], "{name}");
} }
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 only_the_root_agent_defaults_to_managing_the_others() { fn parse_list_falls_back_to_the_agent_default_rather_than_nothing() {
assert_eq!( for raw in ["", " ", ",,", "not_a_group,also_not"] {
Permission::default_for("atlas"), assert_eq!(
Permission::DEFAULT.to_vec() ToolGroup::parse_list(raw),
); ToolGroup::AGENT_DEFAULT.to_vec(),
let ruth = Permission::default_for(crate::manager::MANAGER_AGENT); "{raw:?}"
assert!(ruth.contains(&Permission::ManageRootAgent)); );
assert_eq!(&ruth[..Permission::DEFAULT.len()], Permission::DEFAULT); }
} }
/// Adding permissions only ever adds built-ins — the base set is in every /// Adding groups only ever adds built-ins — the base set is in every
/// resolution, and no permission can remove one. Both `--tools` callers (the /// resolution, and no group 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_permissions_add() { fn builtin_tools_are_the_base_set_plus_whatever_the_groups_add() {
assert_eq!( assert_eq!(
builtin_tools_for(&[]), builtin_tools_for(&[]),
ALLOWED_BUILTIN_TOOLS.to_vec(), ALLOWED_BUILTIN_TOOLS.to_vec(),
"no permissions resolves to exactly the base set" "no groups resolves to exactly the base set"
); );
assert_eq!( assert_eq!(
builtin_tools_for(Permission::DEFAULT), builtin_tools_for(ToolGroup::AGENT_DEFAULT),
ALLOWED_BUILTIN_TOOLS.to_vec(), ALLOWED_BUILTIN_TOOLS.to_vec(),
"the default permissions add no built-ins" "the default groups add no built-ins"
); );
for perm in Permission::ALL { for group in ToolGroup::ALL {
let resolved = builtin_tools_for(&[*perm]); let resolved = builtin_tools_for(&[*group]);
for base in ALLOWED_BUILTIN_TOOLS { for base in ALLOWED_BUILTIN_TOOLS {
assert!(resolved.contains(base), "{perm:?} dropped {base}"); assert!(resolved.contains(base), "{group:?} dropped {base}");
} }
} }
} }
/// `web_tools` is the one permission that widens the built-in set, and it is /// `web_tools` is the one group 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
/// permission. /// group.
#[test] #[test]
fn web_egress_is_present_only_with_the_web_tools_permission() { fn web_egress_is_present_only_with_the_web_tools_group() {
let with = builtin_tools_for(&[Permission::WebTools]); let with = builtin_tools_for(&[ToolGroup::WebTools]);
assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch")); assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch"));
let without = builtin_tools_for(Permission::DEFAULT); let without = builtin_tools_for(ToolGroup::MANAGER_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_for`]), /// means is disputed and release-dependent (see [`builtin_tools_arg`]),
/// 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_permission_combination_resolves_to_an_empty_tools_arg() { fn no_group_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(Permission::ALL).is_empty()); assert!(!builtin_tools_for(ToolGroup::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
@ -290,7 +456,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(Permission::ALL) { for tool in builtin_tools_for(ToolGroup::ALL) {
assert!(!tool.contains("__"), "{tool} looks like an MCP tool name"); assert!(!tool.contains("__"), "{tool} looks like an MCP tool name");
} }
} }
@ -301,7 +467,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(&[Permission::Execution]); let tools = subagent_builtin_tools_for(&[ToolGroup::Execution]);
assert!(tools.contains(&"Bash")); assert!(tools.contains(&"Bash"));
} }
@ -310,27 +476,24 @@ 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 permissions in [&[][..], &[Permission::Messaging, Permission::WebTools][..]] { for groups in [&[][..], &[ToolGroup::Messaging, ToolGroup::WebTools][..]] {
let tools = subagent_builtin_tools_for(permissions); let tools = subagent_builtin_tools_for(groups);
assert!( assert!(!tools.contains(&"Bash"), "{groups:?} must not grant Bash");
!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` /
/// what `hive-agent`'s own `--tools` and /// `builtin_tools_arg` — 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 permissions in [&[][..], &[Permission::Execution][..], Permission::ALL] { for groups in [&[][..], &[ToolGroup::Execution][..], ToolGroup::ALL] {
let tools = builtin_tools_for(permissions); let tools = builtin_tools_for(groups);
assert!( assert!(
!tools.contains(&"Bash"), !tools.contains(&"Bash"),
"{permissions:?} must not grant Bash to the parent" "{groups:?} 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::subagent_builtin_tools_for` — the same `--tools` resolution the parent # `permissions::builtin_tools_arg` — 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,10 +30,7 @@ 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 = [ swarm-queue-client = { workspace = true, features = ["agent-inference"] }
"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::Permission; use hive_sh4re::permissions::ToolGroup;
use swarm_queue_client::agent_inference::AgentInference; use swarm_queue_client::agent_inference::AgentInference;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@ -552,16 +552,12 @@ impl State {
command command
} }
/// Read the swarm's keys once: refresh this agent's permissions (the /// Read the swarm's key once, refuse a `model` outside the list in effect
/// tools the subagent about to run gets), refuse a `model` outside the list /// for this agent (see `check_model`), and return the subagent cap in
/// in effect for this agent (see `check_model`), and return the subagent /// effect (see `subagent_cap`).
/// 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) => Some(reader.read().await),
reader.refresh_permissions().await;
Some(reader.read().await)
}
None => None, None => None,
}; };
let agent = self let agent = self
@ -1055,7 +1051,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` from the harness unit. /// `nix/agent-modules/mcp.nix` the same way `HIVE_TOOL_GROUPS` is.
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
@ -1140,7 +1136,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(()),
// Split, trim, skip empty tokens. // Same split/trim/skip-empty rules as `ToolGroup::parse_list`.
ModelList::Local(Some(env)) => ( ModelList::Local(Some(env)) => (
env.split(',') env.split(',')
.map(str::trim) .map(str::trim)
@ -1190,8 +1186,7 @@ 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_for`] over the /// ones, via [`hive_sh4re::permissions::subagent_builtin_tools_arg`], which
/// 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`).
/// ///
@ -1213,13 +1208,10 @@ 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_for( let tools = hive_sh4re::permissions::subagent_builtin_tools_arg();
&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_for`). Nothing here wants // installed claude release (see `builtin_tools_arg`). 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!(
@ -2071,9 +2063,9 @@ fn acp_runtime(
sessions: &Path, sessions: &Path,
name: &str, name: &str,
) -> AcpRuntime<hive_claude::NeverCompact> { ) -> AcpRuntime<hive_claude::NeverCompact> {
let perms = crate::swarm_models::permissions_now(); let groups = hive_sh4re::permissions::effective_tool_groups();
let permit: PermissionPolicy = let permit: PermissionPolicy =
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &perms)); Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &groups));
AcpRuntime::new( AcpRuntime::new(
command, command,
acp_session_file(sessions, name), acp_session_file(sessions, name),
@ -2087,15 +2079,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` permission; /// - `fetch` with the `web_tools` group;
/// - `execute` with the `execution` permission, as a claude subagent gets `Bash` /// - `execute` with the `execution` group, 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<'_>, perms: &[Permission]) -> bool { fn acp_permits(ask: &PermissionAsk<'_>, groups: &[ToolGroup]) -> 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" => perms.contains(&Permission::WebTools), "fetch" => groups.contains(&ToolGroup::WebTools),
"execute" => perms.contains(&Permission::Execution), "execute" => groups.contains(&ToolGroup::Execution),
_ => false, _ => false,
} }
} }
@ -2992,13 +2984,15 @@ 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 a read one.** Both /// ⚠️ **Scope: this catches a code divergence, not an environment one.**
/// sides resolve off one permission set here; that the parent and this /// Both sides resolve in one process off one `HIVE_TOOL_GROUPS`, so it
/// daemon read the same key is a deployment property. /// cannot see the two real units disagreeing about that var — which they
/// did until `nix/agent-modules/mcp.nix` forwarded it onto this daemon.
/// Nothing in a unit test reaches that; it is a deployment property.
#[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(
&crate::swarm_models::permissions_now(), &hive_sh4re::permissions::effective_tool_groups(),
); );
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(',') {
@ -5418,8 +5412,8 @@ echo '{"type":"result","subtype":"success","is_error":false}'
kind, kind,
mcp_server: None, mcp_server: None,
}; };
let none: &[Permission] = &[]; let none: &[ToolGroup] = &[];
let both = &[Permission::WebTools, Permission::Execution]; let both = &[ToolGroup::WebTools, ToolGroup::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,13 +1,11 @@
//! Reads the subagent settings and permissions the swarm sets for this agent. //! Reads the subagent settings 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`). The agent's permissions, under //! (`crate::session::State::reserve_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
@ -20,35 +18,15 @@
//! [`Read::Failed`]: the caller checks against `HIVE_AVAILABLE_MODELS` and //! [`Read::Failed`]: the caller checks against `HIVE_AVAILABLE_MODELS` and
//! applies no cap. //! applies no cap.
use std::path::Path;
#[cfg(test)] #[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering}; 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::{self, HIVE_COPY_PATH, HiveCopy};
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 what hive-c0re stored for the agent before any read
/// succeeds ([`HiveCopy::before_read`]).
#[must_use]
pub fn permissions_now() -> Vec<Permission> {
let last = LAST_PERMISSIONS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let agent = std::env::var(crate::swarm_term::ENV_AGENT_NAME).unwrap_or_default();
last.unwrap_or_else(|| HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).before_read(&agent))
}
/// 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);
@ -148,44 +126,6 @@ impl Reader {
} }
} }
} }
/// Read the permissions the swarm grants this agent and keep them for
/// [`permissions_now`], judged by [`HiveCopy::after_read`]. 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(HiveCopy::read_at(Path::new(HIVE_COPY_PATH)).after_read(agent, &record))
}
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

@ -43,7 +43,7 @@ use tokio::sync::mpsc;
/// Names the agent. The store role, the store path, the queue token and the /// Names the agent. The store role, the store path, the queue token and the
/// subjects are all built from it. /// subjects are all built from it.
pub(crate) const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME"; const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
/// Where the swarm queue listens, as this container reaches it. /// Where the swarm queue listens, as this container reaches it.
const ENV_NATS_URL: &str = "HIVE_AGENT_NATS_URL"; const ENV_NATS_URL: &str = "HIVE_AGENT_NATS_URL";

View file

@ -19,7 +19,7 @@ let
# Tools opencode must not offer, mirroring claude's built-in allow-list # Tools opencode must not offer, mirroring claude's built-in allow-list
# (hive_sh4re::permissions): no built-in shell (shell is `mcp__bash__run`), # (hive_sh4re::permissions): no built-in shell (shell is `mcp__bash__run`),
# no nested agents, no in-session todo list, no web search. `webfetch` # no nested agents, no in-session todo list, no web search. `webfetch`
# asks, and the harness answers per the `web_tools` permission. Passed as # asks, and the harness answers per the `web_tools` tool group. Passed as
# OPENCODE_PERMISSION because opencode merges that over every config file, # OPENCODE_PERMISSION because opencode merges that over every config file,
# including ones the agent can write. # including ones the agent can write.
opencodePermission = { opencodePermission = {

View file

@ -382,7 +382,17 @@ 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";
# The harness's runtime selection, forwarded from its unit, so an ACP # This daemon resolves each subagent's `--tools` from the same
# tool groups the harness resolves its own session from, so that a
# subagent's built-in tools are its parent agent's and never wider.
# The meta renderer writes this var onto the harness unit alone, so
# forward it from there rather than re-deriving it: without it the
# daemon falls back to the default groups and an agent granted
# `web_tools` would spawn subagents that quietly lack web access.
# `null` when the agent has no groups declared, which systemd drops
# — the same "absent" the harness itself would see.
HIVE_TOOL_GROUPS = config.systemd.services.hive-agent.environment.HIVE_TOOL_GROUPS or null;
# The harness's runtime selection, forwarded the same way, so an ACP
# 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,11 +831,6 @@ 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, inference and permissions keys"; name = "a verified agent's grant is exactly its own subjects, and its JetStream API subjects read its own schedule and inference keys";
ok = ok =
let let
args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart; args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart;
@ -332,7 +332,6 @@ 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,9 +66,6 @@ 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 `Permission` names `/api/agents/{name}/permissions` accepts, the same
# enum the hive enforces.
hive-sh4re.workspace = true
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # Validates `POST /api/agents`' `name` before it becomes `agent`/`repo`
# everywhere downstream — see `create_agent`'s doc comment for why this is # everywhere downstream — see `create_agent`'s doc comment for why this is
# defense-in-depth, not the only gate (per an argus review finding). # defense-in-depth, not the only gate (per an argus review finding).
@ -105,7 +102,6 @@ swarm-authelia-bridge-sock.workspace = true
# carries. # carries.
swarm-queue-client = { workspace = true, features = [ swarm-queue-client = { workspace = true, features = [
"kv", "kv",
"agent-permissions",
"subagent-term", "subagent-term",
"agent-term", "agent-term",
"schema", "schema",

View file

@ -1,737 +0,0 @@
//! `/api/agents/{name}/permissions` — the permission set the swarm grants one
//! agent — and the one-shot import of the grants a hive's meta repo held.
//!
//! Each agent's permissions are one value under its own key of
//! `agent-permissions` (`swarm_queue_client::agent_permissions`), which this
//! daemon alone writes. The agent's harness and subagent daemon apply the tool
//! permissions at their next turn or start; the hive running the agent
//! restarts it when `manage_root_agent` changes. An unset field leaves the
//! agent on the compiled-in default; an empty list grants nothing.
use std::collections::BTreeSet;
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use futures_util::StreamExt as _;
use hive_sh4re::permissions::Permission;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
use utoipa::ToSchema;
use crate::schedules::{live, queue, read_or_default, refused, write, write_revision};
use crate::wanted::WantedWriter;
use crate::{AppState, error_problem, wanted_error_status};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// One agent's permissions. A field left out is not set by the swarm, and the
/// compiled-in default applies; an empty list grants nothing.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct Permissions {
/// Permission names, `snake_case`: `messaging`, `meta`, `inbox`,
/// `execution`, `web_tools`, `manage_root_agent`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Vec<String>>, example = json!(["messaging", "meta", "inbox", "execution"]))]
permissions: Option<Vec<Permission>>,
}
impl Permissions {
/// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentPermissions, String> {
if let Some(perms) = &self.permissions {
unique(perms)?;
}
Ok(AgentPermissions {
permissions: self.permissions,
})
}
}
/// Refuse a list that names a permission twice.
pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for perm in perms {
if !seen.insert(*perm) {
let name: &str = (*perm).into();
return Err(format!("permission {name:?} is listed twice"));
}
}
Ok(())
}
impl From<AgentPermissions> for Permissions {
fn from(v: AgentPermissions) -> Self {
Self {
permissions: v.permissions,
}
}
}
fn agent_name(name: &str) -> Result<String, (StatusCode, String)> {
hive_types::Ident::parse(name)
.map(hive_types::Ident::into_string)
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))
}
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
tracing::warn!(error = %format!("{e:#}"), "agent permissions: queue failure");
error_problem(wanted_error_status(e), &format!("{e:#}"))
}
async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, ProblemDetails> {
agent_permissions::open_or_create(client)
.await
.map_err(|e| queue_problem(&anyhow::Error::new(e)))
}
#[utoipa::path(
get,
path = "/api/agents/{name}/permissions",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "the permissions the swarm sets for the agent; `{}` \
when it sets none", body = Permissions),
(status = 400, description = "not an identifier (problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be read (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn get_agent_permissions(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<Permissions>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let client = queue(&state).map_err(refused)?;
let store = open_or_create(&client).await?;
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
.await
.map_err(|e| queue_problem(&e))?;
Ok(Json(current.into()))
}
/// Set the permissions the swarm grants one agent.
///
/// The value replaces the stored one whole. The hive running the agent reads
/// it within 30 seconds and restarts the agent, after its current turn, when
/// `manage_root_agent` changed; the agent applies the rest at its next turn.
#[utoipa::path(
put,
path = "/api/agents/{name}/permissions",
params(("name" = String, Path, description = "agent name")),
request_body = Permissions,
responses(
(status = 200, description = "the permissions as stored", body = Permissions),
(status = 400, description = "not an identifier, not declared on any hive, or a \
permission listed twice (problem+json)", body = String),
(status = 422, description = "an unknown field or permission name"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be written (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn put_agent_permissions(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<Permissions>,
) -> Result<Json<Permissions>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let value = req
.into_stored()
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?;
if !live(&state).await?.contains(&agent) {
return Err(error_problem(
StatusCode::BAD_REQUEST,
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
));
}
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)
|| write(&store, &agent, &value, revision)
.await
.map_err(|e| queue_problem(&e))?
{
tracing::info!(%agent, ?value, "set agent permissions");
return Ok(Json(value.into()));
}
}
Err(queue_problem(&anyhow::anyhow!(
"gave up writing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
)))
}
#[utoipa::path(
delete,
path = "/api/agents/{name}/permissions",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 204, description = "cleared; the hive's default applies again"),
(status = 400, description = "not an identifier (problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be cleared (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn delete_agent_permissions(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<StatusCode, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let client = queue(&state).map_err(refused)?;
let store = open_or_create(&client).await?;
store
.delete(&agent)
.await
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?;
tracing::info!(%agent, "cleared agent permissions");
Ok(StatusCode::NO_CONTENT)
}
/// Delete a destroyed agent's key. A name is free again once its agent is
/// destroyed, and a create left on the default stores nothing, so a key left
/// here would hand the next agent of that name this one's grant.
///
/// Like the credential revoke it runs beside, a failure is logged and does
/// not stop the destroy; declaring the agent destroyed again re-runs it.
pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) {
let cleared = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
agent_permissions::open_or_create(&client)
.await?
.delete(agent)
.await?;
anyhow::Ok(())
}
.await;
match cleared {
Ok(()) => tracing::info!(hive, agent, "destroyed agent's permissions cleared"),
Err(e) => tracing::error!(
hive,
agent,
error = %format!("{e:#}"),
"clearing this agent's permissions FAILED; it was destroyed anyway, so a new \
agent of the same name created on the default gets its grant. Declaring the \
agent destroyed again re-runs this."
),
}
}
/// Every permission name, and the set an agent the swarm sets nothing for
/// holds — so a client lists them without a copy of its own.
#[derive(Debug, Serialize, ToSchema)]
pub(crate) struct Catalogue {
#[schema(value_type = Vec<String>)]
all: Vec<Permission>,
#[schema(value_type = Vec<String>)]
default: Vec<Permission>,
}
#[utoipa::path(
get,
path = "/api/permissions",
responses((status = 200, description = "every permission name, and the default set",
body = Catalogue)),
tag = "agents"
)]
pub(crate) async fn get_permission_catalogue() -> Json<Catalogue> {
Json(Catalogue {
all: Permission::ALL.to_vec(),
default: Permission::DEFAULT.to_vec(),
})
}
/// The value `POST /api/agents` stores for `perms`, or why it is refused.
/// The agent is not declared on its hive yet, so the "declared by a hive"
/// check of [`put_agent_permissions`] does not apply.
pub(crate) fn at_create(perms: Vec<Permission>) -> Result<AgentPermissions, String> {
unique(&perms)?;
Ok(AgentPermissions {
permissions: Some(perms),
})
}
/// What [`store`] replaced, for [`restore`]; `None` when it wrote nothing.
pub(crate) type Replaced = Option<crate::schedules::Replaced<AgentPermissions>>;
/// Replace `agent`'s stored permissions with `value`.
pub(crate) async fn store(
client: &async_nats::Client,
agent: &str,
value: &AgentPermissions,
) -> Result<Replaced, ProblemDetails> {
let store = open_or_create(client).await?;
for _ in 0..MAX_ATTEMPTS {
let (current, revision, undecodable) = read_or_default::<AgentPermissions>(&store, agent)
.await
.map_err(|e| queue_problem(&e))?;
if current == *value && !undecodable {
tracing::info!(%agent, ?value, "set agent permissions at create");
return Ok(None);
}
// A deleted key reads as present at its marker's revision, so ask
// whether it holds a value before keeping one to put back.
let held = store
.get(agent)
.await
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?
.is_some();
if let Some(written) = write_revision(&store, agent, value, revision)
.await
.map_err(|e| queue_problem(&e))?
{
tracing::info!(%agent, ?value, "set agent permissions at create");
return Ok(Some(crate::schedules::Replaced::new(
held.then_some(current),
written,
)));
}
}
Err(queue_problem(&anyhow::anyhow!(
"gave up writing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
)))
}
/// Undo what [`store`] did, unless another write has landed since. Best
/// effort: a failure is logged, since the caller is already on its way to an
/// error of its own.
pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: Replaced) {
let Some(replaced) = replaced else { return };
let undone = match open_or_create(client).await {
Ok(store) => replaced.undo(&store, agent).await,
Err(e) => Err(anyhow::anyhow!("{e:?}")),
};
match undone {
Ok(true) => {}
Ok(false) => {
tracing::info!(%agent, "left the agent's permissions: another write landed since");
}
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "could not put back the agent's permissions");
}
}
}
/// Create the bucket as soon as the queue is connected, then answer every
/// hive's import. Creating it first means a hive that finds no bucket knows
/// the queue is not serving it, rather than that nothing is set.
pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives: Vec<String>) {
tokio::spawn(async move {
const RETRY: std::time::Duration = std::time::Duration::from_secs(5);
let store = loop {
if swarm_queue_client::ensure_connected(&client).is_ok() {
match agent_permissions::open_or_create(&client).await {
Ok(store) => break store,
Err(e) => tracing::warn!(
error = swarm_queue_client::chain(&e),
"creating the agent-permissions bucket failed; retrying"
),
}
}
tokio::time::sleep(RETRY).await;
};
let subject = agent_permissions::import_subject_wildcard();
loop {
match client.subscribe(subject.clone()).await {
Ok(mut requests) => {
tracing::info!(%subject, "agent permissions: answering imports");
while let Some(msg) = requests.next().await {
let Some(reply) = msg.reply.clone() else {
continue;
};
let answer = answer_import(&store, &wanted, &hives, &msg).await;
let encoded = serde_json::to_vec(&answer).unwrap_or_default();
if let Err(e) = client.publish(reply, encoded.into()).await {
tracing::warn!(error = %e, "agent permissions: answering an import failed");
}
}
tracing::warn!(%subject, "agent permissions: import subscription ended");
}
Err(e) => {
tracing::warn!(%subject, error = %e, "agent permissions: subscribe failed");
}
}
tokio::time::sleep(RETRY).await;
}
});
}
async fn answer_import(
store: &async_nats::jetstream::kv::Store,
wanted: &WantedWriter,
hives: &[String],
msg: &async_nats::Message,
) -> ImportReply {
let failed = |error: String| {
tracing::warn!(subject = %msg.subject, %error, "agent permissions: import refused");
ImportReply::Failed { error }
};
let Some(hive) = agent_permissions::hive_from_import_subject(&msg.subject) else {
return failed(format!("{} is not an import subject", msg.subject));
};
if !hives.iter().any(|h| h == hive) {
return failed(format!("{hive} is not a hive this controller serves"));
}
let import: Import = match serde_json::from_slice(&msg.payload) {
Ok(import) => import,
Err(e) => return failed(format!("the import does not decode: {e}")),
};
let declared = match wanted.view(hive).await {
Ok(d) => crate::agent_renewal::live_agents(d.as_slice()),
Err(e) => return failed(format!("reading {hive}'s wanted state: {e:#}")),
};
match import_into(store, &declared, import).await {
Ok(reply) => {
tracing::info!(%hive, ?reply, "agent permissions: imported a hive's grants");
reply
}
Err(e) => failed(format!("{e:#}")),
}
}
/// Write each agent's imported permissions under its key when it has none,
/// for the agents in `declared` only.
async fn import_into(
store: &async_nats::jetstream::kv::Store,
declared: &BTreeSet<String>,
import: Import,
) -> anyhow::Result<ImportReply> {
let (mut written, mut kept, mut skipped) = (Vec::new(), Vec::new(), Vec::new());
for (agent, caps) in import.permissions {
if !declared.contains(&agent) {
skipped.push(agent);
continue;
}
let value = AgentPermissions {
permissions: Some(caps),
};
if write(store, &agent, &value, None).await? {
written.push(agent);
} else {
kept.push(agent);
}
}
Ok(ImportReply::Imported {
written,
kept,
skipped,
})
}
#[cfg(test)]
mod tests {
use super::{
Permissions, delete_agent_permissions, get_agent_permissions, import_into,
put_agent_permissions,
};
use crate::test_nats::TestNats;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{Import, ImportReply};
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
let store = swarm_queue_client::agent_permissions::open_or_create(client)
.await
.expect("bucket");
store
.get(agent)
.await
.expect("read")
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
}
fn granted() -> Permissions {
Permissions {
permissions: Some(vec![Permission::ManageRootAgent]),
}
}
async fn put(
state: &crate::AppState,
agent: &str,
value: Permissions,
) -> Result<axum::Json<Permissions>, problem_details::ProblemDetails> {
put_agent_permissions(
State(state.clone()),
Path(agent.to_owned()),
axum::Json(value),
)
.await
}
async fn get(state: &crate::AppState, agent: &str) -> Permissions {
get_agent_permissions(State(state.clone()), Path(agent.to_owned()))
.await
.expect("read")
.0
}
/// Through the real handlers: a grant lands as the hive reads it, an empty
/// list is stored as an empty list, and `DELETE` takes the key out.
#[tokio::test]
async fn the_route_writes_what_a_hive_reads_and_delete_clears_it() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
assert_eq!(
put(&state, "atlas", granted()).await.expect("set").0,
granted()
);
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"permissions":["manage_root_agent"]}"#)
);
assert_eq!(get(&state, "atlas").await, granted());
let none = Permissions {
permissions: Some(Vec::new()),
};
let _ = put(&state, "atlas", none.clone()).await.expect("set");
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"permissions":[]}"#)
);
assert_eq!(
delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await,
Ok(StatusCode::NO_CONTENT)
);
assert_eq!(published(&client, "atlas").await, None);
assert_eq!(get(&state, "atlas").await, Permissions::default());
}
/// A name no hive declares, or a name that is not an identifier, is a 400
/// and writes nothing.
#[tokio::test]
async fn an_undeclared_or_malformed_agent_is_a_400() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
for agent in ["iris", "Not_An_Ident"] {
let problem = put(&state, agent, granted()).await.expect_err("refused");
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{agent}");
}
assert_eq!(published(&client, "iris").await, None);
}
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {
use tower::ServiceExt as _;
app.clone()
.oneshot(
axum::http::Request::put("/api/agents/atlas/permissions")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.expect("request"),
)
.await
.expect("router answers")
.status()
}
/// Behind the real router: an unknown field or permission name is a 422,
/// a duplicate is a 400, and none of them changes what is stored.
#[tokio::test]
async fn a_bad_body_is_refused_and_the_grant_stays() {
const GRANTED: &str = r#"{"permissions":["manage_root_agent"]}"#;
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let _ = put(&state, "atlas", granted()).await.expect("set");
let app = crate::build_app(state);
for (body, status) in [
(
r#"{"permissions":["manage_root_agent"],"tool_groups":["meta"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(r#"{"permisions":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
(
r#"{"permissions":["lifecycle"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
r#"{"permissions":["fly_to_the_moon"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
r#"{"permissions":["manage_root_agent","manage_root_agent"]}"#,
StatusCode::BAD_REQUEST,
),
] {
assert_eq!(put_raw(&app, body).await, status, "{body}");
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(GRANTED),
"{body}"
);
}
// Control: the same router and request do reach the bucket.
assert_eq!(put_raw(&app, "{}").await, StatusCode::OK);
assert_eq!(published(&client, "atlas").await.as_deref(), Some("{}"));
}
/// The import writes a key only where there is none, and only for the
/// agents the sending hive declares.
#[tokio::test]
async fn an_import_writes_only_missing_keys_of_the_hives_own_agents() {
let nats = TestNats::start();
let client = nats.client().await;
let store = swarm_queue_client::agent_permissions::open_or_create(&client)
.await
.expect("bucket");
store
.put("atlas", br#"{"permissions":[]}"#.to_vec().into())
.await
.expect("seed");
let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
let import = Import {
permissions: [
("atlas".to_owned(), vec![Permission::ManageRootAgent]),
("ruth".to_owned(), vec![Permission::ManageRootAgent]),
("iris".to_owned(), vec![Permission::ManageRootAgent]),
]
.into(),
};
let reply = import_into(&store, &declared, import)
.await
.expect("imported");
assert_eq!(
reply,
ImportReply::Imported {
written: vec!["ruth".to_owned()],
kept: vec!["atlas".to_owned()],
skipped: vec!["iris".to_owned()],
}
);
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"permissions":[]}"#)
);
assert_eq!(
published(&client, "ruth").await.as_deref(),
Some(r#"{"permissions":["manage_root_agent"]}"#)
);
assert_eq!(published(&client, "iris").await, None);
}
/// `POST /api/agents` with `permissions` stores them under the new agent's
/// key in the same call; a list naming one twice is refused before
/// anything is stored or queued.
#[tokio::test]
async fn create_writes_the_permissions_it_is_given() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let create = |name: &str, permissions| {
crate::create_agent(
State(state.clone()),
axum::Json(crate::CreateAgentRequest {
name: name.to_owned(),
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions,
}),
)
};
let problem = create(
"iris",
Some(vec![Permission::Messaging, Permission::Messaging]),
)
.await
.expect_err("a duplicate is refused");
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
assert_eq!(published(&client, "iris").await, None);
let queued = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph()
.nodes()
.count();
assert_eq!(queued, 0, "nothing is queued for a refused create");
let _ = create("iris", Some(vec![Permission::Messaging]))
.await
.expect("created");
assert_eq!(
published(&client, "iris").await.as_deref(),
Some(r#"{"permissions":["messaging"]}"#)
);
// Control: no `permissions` writes no key, so the default applies.
let _ = create("damocles", None).await.expect("created");
assert_eq!(published(&client, "damocles").await, None);
}
/// Destroying an agent deletes its key, so a new agent of the same name
/// created on the default gets the default, not the old grant.
#[tokio::test]
async fn a_recreated_agent_does_not_inherit_the_destroyed_ones_grant() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let _ = put(&state, "atlas", granted()).await.expect("set");
let _ = crate::set_agent_state(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned())),
axum::Json(crate::SetAgentStateRequest {
state: swarm_queue_client::wanted::AgentState::Destroyed,
}),
)
.await
.expect("destroyed");
let _ = crate::create_agent(
State(state.clone()),
axum::Json(crate::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
}),
)
.await
.expect("recreated");
assert_eq!(published(&client, "atlas").await, None);
assert_eq!(get(&state, "atlas").await, Permissions::default());
}
#[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

@ -47,7 +47,6 @@ mod agent_identity;
mod agent_input; mod agent_input;
mod agent_limits; mod agent_limits;
mod agent_login; mod agent_login;
mod agent_permissions;
mod agent_renewal; mod agent_renewal;
mod agent_state_stream; mod agent_state_stream;
mod agent_status; mod agent_status;
@ -1410,12 +1409,11 @@ async fn set_agent_state(
// After the declaration and never before it: the write above is the // After the declaration and never before it: the write above is the
// destroy order, and until it lands the agent is not being torn down, so // destroy order, and until it lands the agent is not being torn down, so
// its credential and permissions have to keep working. Sequenced rather // its credential has to keep working. Sequenced rather than spawned so
// than spawned so both attempts are finished — and their outcomes // the revocation attempt is finished — and its outcome logged — by the
// logged — by the time the operator's call returns. // time the operator's call returns.
if revokes_queue_credential(req.state) { if revokes_queue_credential(req.state) {
revoke_or_complain(&hive, &agent).await; revoke_or_complain(&hive, &agent).await;
agent_permissions::clear_on_destroy(&state, &hive, &agent).await;
} }
Ok(Json(render(&declaration))) Ok(Json(render(&declaration)))
} }
@ -1743,11 +1741,6 @@ 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
@ -1801,10 +1794,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, `limits` or `inference` holds a value the matching `PUT` refuses, or a permission is listed twice (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, or `limits` or `inference` holds a value the matching `PUT` refuses (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`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"), (status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, or an `effort` that is not one of the five levels"),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference` or `permissions` 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` or `inference` 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"
@ -1819,7 +1812,15 @@ 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 settings = CreateSettings::parse(req.limits, req.inference, req.permissions) let limits = req
.limits
.map(agent_limits::Caps::into_stored)
.transpose()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
let inference = req
.inference
.map(inference::InferenceSettings::into_stored)
.transpose()
.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
@ -1883,7 +1884,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 settings.any() { let settings_queue = if limits.is_some() || inference.is_some() {
Some(schedules::queue(&state).map_err(schedules::refused)?) Some(schedules::queue(&state).map_err(schedules::refused)?)
} else { } else {
None None
@ -1914,14 +1915,7 @@ 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( StoredSettings::store(client, &agent, limits.as_ref(), inference.as_ref()).await?
client,
&agent,
settings.limits.as_ref(),
settings.inference.as_ref(),
settings.permissions.as_ref(),
)
.await?
} }
None => StoredSettings::default(), None => StoredSettings::default(),
}; };
@ -1953,67 +1947,27 @@ 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`, `permissions` and `inference` are given, in /// Store whichever of `limits` and `inference` are given. A failure on
/// that order. A failure on one puts back the ones stored before it. /// the second puts back the first.
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,
@ -2029,7 +1983,6 @@ 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;
} }
} }
@ -2924,22 +2877,11 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
} }
/// The per-agent background passes: subagent and terminal streams, and /// The per-agent background passes: subagent and terminal streams, and
/// schedule keys, and the permissions bucket and its import. /// schedule keys.
/// Lifted out of `main` for `clippy::too_many_lines`. /// Lifted out of `main` for `clippy::too_many_lines`.
fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) { fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
spawn_subagent_streams(status, hives); spawn_subagent_streams(status, hives);
spawn_schedule_reconcile(status, hives); spawn_schedule_reconcile(status, hives);
spawn_agent_permissions(status, hives);
}
/// Create the agent-permissions bucket and answer hives' imports when a swarm
/// queue is wired up. Same shape as [`spawn_subagent_streams`].
fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
agent_permissions::spawn(status.queue_client(), wanted, hives);
} }
/// Start rewriting every agent's schedule key from the definitions when a /// Start rewriting every agent's schedule key from the definitions when a
@ -3551,12 +3493,6 @@ fn build_app(state: AppState) -> axum::Router {
agent_limits::put_agent_limits, agent_limits::put_agent_limits,
agent_limits::delete_agent_limits agent_limits::delete_agent_limits
)) ))
.routes(routes!(
agent_permissions::get_agent_permissions,
agent_permissions::put_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))
@ -3818,7 +3754,6 @@ mod tests {
hive: "pr1maa".to_owned(), hive: "pr1maa".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -3859,7 +3794,6 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -4022,7 +3956,6 @@ 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");
@ -4041,71 +3974,6 @@ 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, and the refusal is that bucket's.
#[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");
let err = super::create_agent(axum::extract::State(state.clone()), axum::Json(req))
.await
.expect_err("the inference write must fail");
assert_eq!(
err.status,
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"{agent}: {err:?}"
);
assert!(
err.detail
.as_deref()
.is_some_and(|d| d.contains("message size exceeds maximum allowed")),
"{agent}: refused by the inference bucket, not an earlier check: {err:?}"
);
}
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]
@ -4159,7 +4027,6 @@ mod tests {
hive: hive.to_owned(), hive: hive.to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5282,7 +5149,6 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5336,7 +5202,6 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5386,7 +5251,6 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5444,7 +5308,6 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5649,7 +5512,6 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await

View file

@ -229,9 +229,7 @@ impl Policy {
return Some(Permissions { return Some(Permissions {
publish: self.reader_subjects(), publish: self.reader_subjects(),
subscribe: Some(Self::reader_subscribe(client_id)), subscribe: Some(Self::reader_subscribe(client_id)),
// The answer to a hive's permissions import. NATS lets it responses: None,
// answer only a message it received, once.
responses: Some(swarm_queue_client::agent_permissions::IMPORT_REPLY_DEADLINE),
}); });
} }
if self.review_classifiers.iter().any(|c| c == client_id) { if self.review_classifiers.iter().any(|c| c == client_id) {
@ -309,7 +307,7 @@ impl Policy {
/// What a reader may subscribe to: every agent's terminal, its /// What a reader may subscribe to: every agent's terminal, its
/// subagents' terminals and its turn state, which the controller relays /// subagents' terminals and its turn state, which the controller relays
/// to swarm-ui, every hive's permissions import, and its own reply inbox. /// to swarm-ui, and its own reply inbox.
/// ///
/// The three families are the publishers' (`hive-agent`'s `swarm_term` /// The three families are the publishers' (`hive-agent`'s `swarm_term`
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled /// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled
@ -320,7 +318,6 @@ impl Policy {
swarm_queue_client::term_subject_wildcard(), swarm_queue_client::term_subject_wildcard(),
swarm_queue_client::subagent_term::subject_wildcard(), swarm_queue_client::subagent_term::subject_wildcard(),
swarm_queue_client::agent_state_subject_wildcard(), swarm_queue_client::agent_state_subject_wildcard(),
swarm_queue_client::agent_permissions::import_subject_wildcard(),
Self::inbox(client_id), Self::inbox(client_id),
] ]
} }
@ -564,17 +561,6 @@ impl Policy {
"$JS.API.DIRECT.GET.{}.>", "$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_limits::STREAM swarm_queue_client::agent_limits::STREAM
)); ));
// The permissions the swarm grants those agents
// (`swarm_queue_client::agent_permissions`), read the same way and
// every key for the same reason.
subjects.push(format!(
"$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_permissions::STREAM
));
// The one-shot import of the grants this hive's meta repo held, sent
// as a request on this hive's own subject: the controller takes the
// hive from the subject, so one hive cannot import for another.
subjects.push(swarm_queue_client::agent_permissions::import_subject(hive));
subjects.extend( subjects.extend(
self.extra_hive_subjects self.extra_hive_subjects
.iter() .iter()
@ -851,76 +837,6 @@ mod tests {
} }
} }
/// A hive reads any agent's permissions and can change none of them; an
/// agent, with the template `swarm-nats.nix` passes, reads its own key and
/// no other, and writes none.
#[test]
fn a_hive_reads_every_agents_permissions_and_an_agent_only_its_own() {
use swarm_queue_client::agent_permissions::{STREAM, key_subject};
let direct_get_subject = |agent: &str| {
format!("$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}")
};
let p = policy()
.with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.{agent}".to_owned(),
])
.expect("a per-agent template is valid");
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
for agent in ["atlas", "ruth"] {
assert!(hive_may(&direct_get_subject(agent)), "cannot read {agent}");
}
for forbidden in [
key_subject("atlas"),
format!("$JS.API.STREAM.CREATE.{STREAM}"),
format!("$JS.API.STREAM.DELETE.{STREAM}"),
format!("$JS.API.STREAM.PURGE.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
] {
assert!(!hive_may(&forbidden), "hive alpha may publish {forbidden}");
}
let agent = p.agent_token_permissions("atlas").expect("configured");
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
assert!(agent_may(&direct_get_subject("atlas")));
for forbidden in [direct_get_subject("ruth"), key_subject("atlas")] {
assert!(!agent_may(&forbidden), "atlas may publish {forbidden}");
}
}
/// A hive sends its import on its own subject only; the reader hears every
/// hive's and may answer, and no other role may send one.
#[test]
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
use swarm_queue_client::agent_permissions::{
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
};
let p = policy();
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
assert!(hive_may(&import_subject("alpha")));
assert!(!hive_may(&import_subject("beta")));
let reader = p.permissions("swarm-controller").expect("the reader");
assert!(
reader
.subscribe
.as_ref()
.expect("scoped")
.contains(&import_subject_wildcard())
);
assert_eq!(reader.responses, Some(IMPORT_REPLY_DEADLINE));
assert!(
!reader
.publish
.iter()
.any(|g| covers(g, &import_subject("alpha"))),
"the reader may send an import as a hive"
);
}
#[test] #[test]
fn an_extra_subject_without_the_placeholder_is_refused() { fn an_extra_subject_without_the_placeholder_is_refused() {
// 🩸 The option exists to put a second stream inside ONE hive's // 🩸 The option exists to put a second stream inside ONE hive's
@ -1440,6 +1356,7 @@ mod tests {
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let reader = p.permissions("swarm-controller").expect("the reader"); let reader = p.permissions("swarm-controller").expect("the reader");
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT))); assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
assert_eq!(reader.responses, None);
for (who, grant) in [ for (who, grant) in [
("a hive", p.permissions("hive-alpha").expect("a hive")), ("a hive", p.permissions("hive-alpha").expect("a hive")),
( (

View file

@ -50,11 +50,6 @@ 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, the agent
# harness, the subagent daemon and swarm-controller. Pulls `hive-sh4re` for the
# `Permission` names, so the crates that only need the bucket name do not link
# it.
agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"]
[dependencies] [dependencies]
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature # Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
@ -63,7 +58,6 @@ async-nats.workspace = true
# `Shared`, so every caller refused while a secret re-read runs waits on that # `Shared`, so every caller refused while a secret re-read runs waits on that
# one read (`stored_secret`). # one read (`stored_secret`).
futures-util.workspace = true futures-util.workspace = true
hive-sh4re = { workspace = true, optional = true }
# `blocking` on top of the workspace default (`form`/`json`/`rustls`) — # `blocking` on top of the workspace default (`form`/`json`/`rustls`) —
# `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller # `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller
# with no tokio reactor to `.await` an async request on (an OTLP exporter's # with no tokio reactor to `.await` an async request on (an OTLP exporter's

View file

@ -1,346 +0,0 @@
//! The per-agent permissions KV bucket: one key per agent, holding the
//! [`hive_sh4re::permissions::Permission`] set the swarm grants it.
//!
//! **swarm-controller is the only writer**, and creates the bucket when it
//! starts. A missing key or an unset `permissions` field leaves the agent on
//! the compiled-in default (`Permission::default_for`); an empty list grants
//! nothing.
//!
//! **Two readers, each enforcing its own part.** The hive running the agent
//! applies `manage_root_agent` as bind mounts at container start; its grant
//! covers every key of this bucket, read-only, for the reason
//! [`crate::agent_limits`] gives. The agent itself (its harness and subagent
//! daemon) applies the tool permissions; its grant covers only its own key.
//! Both read with one `DIRECT.GET` on the agent's key
//! ([`crate::agent_permissions::key_subject`]).
//!
//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move
//! between hives, and its permissions go with it.
//!
//! Also carries the one-shot import a hive sends of the grants its meta repo
//! held ([`crate::agent_permissions::import_subject`]), answered by the
//! controller.
/// The KV bucket permissions are published into, keyed by agent name.
pub const BUCKET: &str = "agent-permissions";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agent-permissions";
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
const IMPORT_SUBJECT_PREFIX: &str = "$SWARM.permissions-import";
/// The subject `hive` sends its import on. One per hive, so the hive's grant
/// names only its own, and the controller takes the hive from the subject
/// rather than from the payload.
#[must_use]
pub fn import_subject(hive: &str) -> String {
format!("{IMPORT_SUBJECT_PREFIX}.{hive}")
}
/// Every [`import_subject`] at once, for the controller's subscription.
#[must_use]
pub fn import_subject_wildcard() -> String {
import_subject("*")
}
/// The hive an [`import_subject`] names, or `None` for any other subject.
#[must_use]
pub fn hive_from_import_subject(subject: &str) -> Option<&str> {
let hive = subject
.strip_prefix(IMPORT_SUBJECT_PREFIX)?
.strip_prefix('.')?;
(!hive.is_empty() && !hive.contains('.')).then_some(hive)
}
/// How long a hive waits for the controller's answer to an import, and the
/// queue lets the controller answer.
pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// One agent's permissions: the value under `BUCKET`/`<agent>`. `None` is
/// "not set by the swarm", which a hive reads as its default.
///
/// No `deny_unknown_fields`: a field a newer controller adds must not cost an
/// older hive the fields it knows.
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
}
/// What a hive sends once: the permissions its meta repo held, by agent.
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Import {
pub permissions: std::collections::BTreeMap<String, Vec<hive_sh4re::permissions::Permission>>,
}
/// The controller's answer to an [`Import`].
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ImportReply {
/// Every agent was handled. `written` had no key and got the hive's value;
/// `kept` already had a key, which was left alone; `skipped` is not an
/// agent the sending hive's wanted state declares.
Imported {
written: Vec<String>,
kept: Vec<String>,
skipped: Vec<String>,
},
/// Nothing is known to be written; the hive sends again later.
Failed { error: String },
}
/// Open the permissions bucket, creating it if nothing has yet.
///
/// **Controller-side only.** `history: 1`: a hive wants the current value.
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
let js = async_nats::jetstream::new(client.clone());
crate::kv::open_or_create(
&js,
BUCKET,
"agent-permissions",
"Permissions the swarm controller sets for each agent".to_owned(),
)
.await
}
/// What reading one agent's key found.
#[cfg(feature = "agent-permissions")]
#[derive(Debug)]
pub enum Read {
/// The bucket answered; an absent or deleted key has every field unset.
Current(AgentPermissions),
/// Nothing answers for the bucket's stream. Not an unset value: it says
/// nothing about the agent's permissions.
NoBucket,
}
/// The file hive-c0re writes an agent's [`HiveCopy`] to in that agent's
/// runtime dir.
#[cfg(feature = "agent-permissions")]
pub const HIVE_COPY_FILE: &str = "swarm-permissions.json";
/// Where the agent's own processes read [`HIVE_COPY_FILE`]: the runtime dir
/// is mounted at `/run/hive` in the container.
#[cfg(feature = "agent-permissions")]
pub const HIVE_COPY_PATH: &str = "/run/hive/swarm-permissions.json";
/// What hive-c0re holds for one agent: its record as the hive last stored it
/// (read from this bucket, or taken from the meta repo before the import),
/// and whether the meta repo's import is still pending. The agent's harness
/// and subagent daemon hold [`HiveCopy::before_read`] until their own first
/// read of the key succeeds, and judge every read with
/// [`HiveCopy::after_read`].
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HiveCopy {
#[serde(flatten)]
pub stored: AgentPermissions,
#[serde(default)]
pub import_pending: bool,
}
#[cfg(feature = "agent-permissions")]
impl HiveCopy {
/// The copy at `path`. A missing or unreadable file is the empty copy,
/// which holds the default and has no import pending.
#[must_use]
pub fn read_at(path: &std::path::Path) -> Self {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "permissions: the hive's copy is unreadable");
return Self::default();
}
};
serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!(path = %path.display(), error = %e, "permissions: the hive's copy does not decode");
Self::default()
})
}
/// What `agent` holds before any read of its key has succeeded: the
/// stored list, or the default when the hive stored none.
#[must_use]
pub fn before_read(&self, agent: &str) -> Vec<hive_sh4re::permissions::Permission> {
self.stored
.permissions
.clone()
.unwrap_or_else(|| hive_sh4re::permissions::Permission::default_for(agent))
}
/// What `agent` holds after reading `read` from its key. An unset key
/// while the import is pending may only mean the grant is not imported
/// yet, so the stored list stands; otherwise unset is the default.
#[must_use]
pub fn after_read(
&self,
agent: &str,
read: &AgentPermissions,
) -> Vec<hive_sh4re::permissions::Permission> {
match &read.permissions {
Some(list) => list.clone(),
None if self.import_pending => self.before_read(agent),
None => hive_sh4re::permissions::Permission::default_for(agent),
}
}
}
/// 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
///
/// A failed request or an undecodable value. A request the queue refused for
/// permissions reaches the client as a timeout, not as a refusal.
#[cfg(feature = "agent-permissions")]
pub async fn read(
client: &async_nats::Client,
agent: &str,
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
use async_nats::jetstream::stream::DirectGetErrorKind;
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream_no_info(STREAM).await?;
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
Ok(message) => message,
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
return Ok(Read::Current(AgentPermissions::default()));
}
Err(e)
if matches!(
e.kind(),
DirectGetErrorKind::ErrorResponse(status, _)
if status == async_nats::StatusCode::NO_RESPONDERS
) =>
{
return Ok(Read::NoBucket);
}
Err(e) => return Err(e.into()),
};
Ok(Read::Current(decode(
message
.headers
.get("KV-Operation")
.map(async_nats::HeaderValue::as_str),
&message.payload,
)?))
}
/// One stored message as a value: a KV delete or purge is stored as an empty
/// marker carrying the `KV-Operation` header, and reads as every field unset.
#[cfg(feature = "agent-permissions")]
fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<AgentPermissions> {
if matches!(operation, Some("DEL" | "PURGE")) {
return Ok(AgentPermissions::default());
}
serde_json::from_slice(payload)
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grants in `swarm-nats-auth` are built from.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agent-permissions.iris");
assert_eq!(import_subject("pr1ma"), "$SWARM.permissions-import.pr1ma");
assert_eq!(import_subject_wildcard(), "$SWARM.permissions-import.*");
}
#[test]
fn the_hive_is_read_back_from_its_import_subject_only() {
assert_eq!(
hive_from_import_subject(&import_subject("pr1ma")),
Some("pr1ma")
);
for other in [
"$SWARM.permissions-import",
"$SWARM.permissions-import.",
"$SWARM.permissions-import.a.b",
"$SWARM.permissions-importx.pr1ma",
"$SWARM.deploy.pr1ma",
] {
assert_eq!(hive_from_import_subject(other), None, "{other}");
}
}
/// Unset is left out on the wire, `{}` is unset, and an empty list stays
/// an empty list: "grants nothing" is not "not set".
#[cfg(feature = "agent-permissions")]
#[test]
fn unset_and_empty_are_different_values() {
use hive_sh4re::permissions::Permission;
assert_eq!(
serde_json::to_string(&AgentPermissions::default()).unwrap(),
"{}"
);
let none: AgentPermissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
assert_eq!(none.permissions, Some(Vec::new()));
let ruth: AgentPermissions =
serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
}
/// Before a read the stored list stands; after one, a set key wins and an
/// unset key is the default unless the import is pending.
#[cfg(feature = "agent-permissions")]
#[test]
fn the_hive_copy_stands_until_read_and_while_the_import_is_pending() {
use hive_sh4re::permissions::Permission;
let record = |json: &str| -> AgentPermissions { serde_json::from_str(json).unwrap() };
let copy = |json: &str| -> HiveCopy { serde_json::from_str(json).unwrap() };
let web = vec![Permission::WebTools];
let default = Permission::DEFAULT.to_vec();
assert_eq!(
copy(r#"{"permissions":["web_tools"]}"#).before_read("atlas"),
web
);
assert_eq!(copy("{}").before_read("atlas"), default);
let pending = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#);
assert_eq!(pending.after_read("atlas", &record("{}")), web);
assert_eq!(
pending.after_read("atlas", &record(r#"{"permissions":[]}"#)),
Vec::new()
);
let settled = copy(r#"{"permissions":["web_tools"]}"#);
assert_eq!(settled.after_read("atlas", &record("{}")), default);
}
/// Fields a newer controller adds must not cost an older hive the ones it
/// knows; a deleted key is unset.
#[cfg(feature = "agent-permissions")]
#[test]
fn decoding_tolerates_unknown_fields_and_reads_a_delete_as_unset() {
let decoded = decode(
None,
br#"{"permissions":["manage_root_agent"],"later_field":["meta"]}"#,
)
.unwrap();
assert_eq!(decoded.permissions.map(|c| c.len()), Some(1));
assert_eq!(
decode(Some("DEL"), b"").unwrap(),
AgentPermissions::default()
);
assert_eq!(
decode(Some("PURGE"), b"").unwrap(),
AgentPermissions::default()
);
}
}

View file

@ -221,12 +221,6 @@ pub mod agent_inference;
/// writing needs `kv` and reading needs `agent-limits`. /// writing needs `kv` and reading needs `agent-limits`.
pub mod agent_limits; pub mod agent_limits;
/// The bucket swarm-controller sets each agent's permissions in — one key
/// per agent, read by the hive that runs it, plus the one-shot import a hive
/// sends. The names are unconditional; writing needs `kv` and the stored type
/// and reading need `agent-permissions`.
pub mod agent_permissions;
/// The login flag in an agent's turn-state header. No bucket: it travels /// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only. /// on [`agent_state_subject`] only.
pub mod agent_login; pub mod agent_login;

View file

@ -29,7 +29,6 @@ mod agent;
mod forge; mod forge;
mod inference; mod inference;
mod login; mod login;
mod permissions;
mod remote; mod remote;
mod schedule; mod schedule;
mod users; mod users;
@ -291,45 +290,6 @@ enum AgentVerb {
#[command(subcommand)] #[command(subcommand)]
command: InferenceVerb, command: InferenceVerb,
}, },
/// Show or change the permissions the swarm grants an agent.
///
/// The agent applies its tool permissions at its next turn; the hive
/// running it restarts it, after its current turn, when
/// `manage_root_agent` changes. Unset leaves the agent on the default:
/// `messaging`, `meta`, `inbox`, `execution`, plus `manage_root_agent`
/// for `ruth`.
Permissions {
#[command(subcommand)]
command: PermissionsVerb,
},
}
#[derive(Subcommand)]
enum PermissionsVerb {
/// Show the permissions the swarm grants an agent, or that it sets none.
Get(InferenceAgentArgs),
/// Replace the permissions the swarm grants an agent.
Set(PermissionsSetArgs),
/// Clear the agent's permissions, so the default applies again.
Clear(InferenceAgentArgs),
}
#[derive(Args)]
struct PermissionsSetArgs {
/// Agent name. A hive must declare it.
agent: String,
/// The permissions to grant, comma-separated: any of `messaging`,
/// `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`.
/// `--permissions ''` grants none.
#[arg(
long,
value_name = "PERMISSIONS",
value_delimiter = ',',
required = true
)]
permissions: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -642,9 +602,6 @@ fn main() -> Result<()> {
Verb::Agent { Verb::Agent {
command: AgentVerb::Inference { command }, command: AgentVerb::Inference { command },
} => inference(&remote, command), } => inference(&remote, command),
Verb::Agent {
command: AgentVerb::Permissions { command },
} => permissions(&remote, command),
// Same socket-resolution reasoning as `Create` above. // Same socket-resolution reasoning as `Create` above.
Verb::Forge { Verb::Forge {
command: ForgeVerb::MakeAdmin(args), command: ForgeVerb::MakeAdmin(args),
@ -714,16 +671,6 @@ fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> {
} }
} }
fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
match command {
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
PermissionsVerb::Set(a) => {
permissions::set(&a.socket.resolve(remote)?, &a.agent, a.permissions)
}
PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
}
}
fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> { fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> {
match command { match command {
ScheduleVerb::Add(a) => schedule::add( ScheduleVerb::Add(a) => schedule::add(

View file

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