Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
0e7aa74d00 agent permissions: drop the test-only direct_get_subject; the failed-creation test names the refusing step 2026-10-15 18:01:40 +02:00
atlas
89013b5408 agent permissions: hold hive-c0re's stored grant until the agent's own read
Until its first successful read of its key, the harness read its
permissions as the default set, `execution` included. So did the
subagent daemon. An agent the operator had narrowed ran wider than
granted after every restart while the queue was unreachable, and in the
gap before the first 10 s poll. During the meta-repo import, every
non-ruth key is still unset, and the agent read that as the default too,
for as long as the import stayed pending.

mara picked option (a) on #5293: "a, removal of the old path as a follow
up in 1w".

hive-c0re now writes each agent's entry from
swarm-agent-permissions.json into the agent's runtime dir as
swarm-permissions.json (/run/hive in the container), together with
whether the import is still pending. It writes the file at every start
(write_dropins) and after every 30 s pass of the permissions worker. The
harness and the subagent daemon hold that stored list until their own
first read succeeds. While the import is pending, an unset key keeps
the stored list rather than reverting it to the default. A queue that
is down never blocks a start: the agent runs on the stored grant.

The decision lives in swarm_queue_client::agent_permissions::HiveCopy,
shared by both readers.

Part of #5170.
2026-10-15 18:01:40 +02:00
atlas
4f9e9bd680 agent permissions: a destroyed agent's key goes with it
Declaring an agent Destroyed now deletes its `agent-permissions` key,
next to the queue-credential revoke and with the same failure handling:
logged at error, the destroy goes ahead, and declaring it destroyed
again re-runs it. Agent names are reusable after a destroy, and a create
left on the default stores no key, so the old key handed a new agent of
the same name the destroyed agent's grant (argus on #5293).

Also: migrate.rs's create-time-setup note named ruth's tool-group seed
in `ensure_root_agent`, which this PR deletes; it now says she holds the
code-constant default when the swarm sets nothing.

Part of #5170.
2026-10-15 18:01:40 +02:00
atlas
a937a8ba9a permissions: drop the capability and tool-group wording left after the rework 2026-10-15 18:01:40 +02:00
atlas
f0844f6da3 agent permissions: one set, tool groups included, set at agent creation
mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

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

Part of #5170.
2026-10-15 18:01:40 +02:00
atlas
f862354e43 agent capabilities live in a swarm KV record, set from swarmctl
An agent's capabilities move from the hive's meta repo
(`meta/capabilities.json`, injected as `HIVE_CAPABILITIES`) into the
`capabilities` field of its key in a new `agent-permissions` KV bucket,
which swarm-controller alone writes.

- swarm-queue-client: `agent_permissions` (bucket, key, `AgentPermissions`,
  direct-get read, the import subject and its wire types).
- swarm-controller: creates the bucket at start; `GET|PUT|DELETE
  /api/agents/{name}/permissions` (deny_unknown_fields and unknown
  capability ⇒ 422, duplicate ⇒ 400, agent must be declared by a hive);
  answers each hive's one-shot import, writing a key only where there is
  none and only for agents that hive's wanted state declares.
- swarmctl: `agent permissions get|set|clear`.
- swarm-nats-auth: a hive reads every key of the bucket and sends its
  import on its own subject; the controller subscribes to the imports and
  may answer them (`responses`).
- hive-c0re: reads each of its agents' keys every 30s into
  /var/lib/hyperhive/swarm-agent-permissions.json and restarts an agent,
  gracefully, when its capabilities change. Unset is the compiled-in
  default (ruth: manage_root_agent; everyone else: none). A failed read
  keeps the stored value. On first boot it seeds that file from
  meta/capabilities.json, sends the grants to the controller, and deletes
  the file once the controller answers.

Deleted: the capabilities.json reader/writer, `HIVE_CAPABILITIES` in the
rendered meta flake, ruth's capability seed, the capabilities half of the
permissions rebuild job (`PermPayload::Capabilities`/`Combined`), the
dashboard's capabilities table and `capabilities_changed` event, and the
now-unused `Capability::description`.

Part of #5170.
2026-10-15 18:01:40 +02:00
83 changed files with 3419 additions and 3438 deletions

3
Cargo.lock generated
View file

@ -2049,7 +2049,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"strum", "strum",
"tracing",
] ]
[[package]] [[package]]
@ -4884,6 +4883,7 @@ 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,6 +5014,7 @@ 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 capability?** → - **What's the operator/agent trust boundary? What's a permission?** →
[`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 capability store scopes which agents can Agents are a **flat set**: the `manage_root_agent` permission scopes
manage which others. which agents can manage which others.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
@ -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` capability's bind-mounts this hive's local agents the `ManageRootAgent` permission'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` capability gets bind-mounted (each other holding the `ManageRootAgent` permission 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 capability sees its own dirs and nothing else: agent holding no permission 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 capability holder loses its roster costs more than a cosmetic gap: every permission 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 capability grant; its prompt, tool its socket flavour and its default permissions; its prompt, tool
allow-list and state dirs work as for every other agent. A hive with allow-list and state dirs work as for every other agent. A hive with
`services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips `services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips
its root-agent create/start sweep its root-agent create/start sweep
@ -107,7 +107,7 @@ apply.
manage any agent's state dir — config isn't authored there, since a manage any agent's state dir — config isn't authored there, since a
real config change is a PR from a clone), plus RO mounts for real config change is a PR from a clone), plus RO mounts for
`/applied` (diff against what's deployed) and `/meta` (system-wide `/applied` (diff against what's deployed) and `/meta` (system-wide
deploy log). That grant is the `ManageRootAgent` capability: ruth deploy log). That grant is the `manage_root_agent` permission: ruth
holds it by default, and any agent holding it gets the same mounts. holds it by default, and any agent holding it gets the same mounts.
The operator dashboard's `POST /api/meta-update` is the only path The operator dashboard's `POST /api/meta-update` is the only path
that writes `flake.lock`. that writes `flake.lock`.
@ -118,11 +118,10 @@ apply.
`hive-agent/src/prompt.rs`'s own module doc). The `role:manager` `hive-agent/src/prompt.rs`'s own module doc). The `role:manager`
blocks are dead in production, exercised only by a unit test blocks are dead in production, exercised only by a unit test
(`filter_role_blocks(SAMPLE, "manager")`). (`filter_role_blocks(SAMPLE, "manager")`).
- **Tool allow-list** — the MCP tools claude sees come from - **Tool allow-list** — the MCP tools claude sees come from the agent's
`HIVE_TOOL_GROUPS` alone, the same mechanism for every agent permissions alone, the same mechanism for every agent
([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's wider default ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's default
surface is a wider default grant (`ToolGroup::MANAGER_DEFAULT`, seeded permissions add only `manage_root_agent` (`Permission::default_for`).
by `auto_update.rs` whenever its groups aren't already set).
- **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is - **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is
injected uniformly via `systemd.globalEnvironment` for every injected uniformly via `systemd.globalEnvironment` for every
container including the manager, so all token/state paths resolve container including the manager, so all token/state paths resolve

View file

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

View file

@ -329,14 +329,14 @@ Under `/var/lib/hyperhive/agents/<name>/`:
### Cross-agent access to state ### Cross-agent access to state
An agent holding the `ManageRootAgent` capability gets every other An agent holding the `manage_root_agent` permission gets every other
agent's `state` dir bind-mounted **read-write** and its `config` dir agent's `state` dir bind-mounted **read-write** and its `config` dir
**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`). **read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`).
The RW on `state` is deliberate, not an oversight: the holder recovers The RW on `state` is deliberate, not an oversight: the holder recovers
other agents, which includes writing into their state (for example other agents, which includes writing into their state (for example
seeding notes, clearing a stuck sentinel) as well as reading it. seeding notes, clearing a stuck sentinel) as well as reading it.
This is the **only** cross-agent mount. An agent holding no capability This is the **only** cross-agent mount. An agent without that permission
sees its own dirs and nothing else. sees its own dirs and nothing else.
**`harness` isn't mounted at all.** It holds that agent's own runtime **`harness` isn't mounted at all.** It holds that agent's own runtime
@ -364,7 +364,7 @@ mount should be writable when it shouldn't.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
Isolation still holds by default: a container has its *own* dirs and, Isolation still holds by default: a container has its *own* dirs and,
unless it holds the capability, nothing else. unless it holds the permission, nothing else.
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never applied repo. Tracks `flake.nix` (module-only boilerplate; never
@ -384,17 +384,9 @@ Contents:
audit trail (one commit per successful deploy or hyperhive bump). audit trail (one commit per successful deploy or hyperhive bump).
- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`). - `topology.json` — the agent roster (`["alice", "bob", "ruth"]`).
Written by `topology::reconcile` on every meta sync; read by Written by `topology::reconcile` on every meta sync; read by
`topology::all_agents`, which is the set the `ManageRootAgent` `topology::all_agents`, which is the set the `manage_root_agent`
capability grants mounts over. The reader also accepts a map-shaped permission grants mounts over. The reader also accepts a map-shaped
file carrying a `parent` per agent, and keeps its keys. file carrying a `parent` per agent, and keeps its keys.
- `tool-groups.json` — per-agent MCP tool group grants
(`{ "alice": ["messaging", "inbox", "execution"] }`). Written by
`tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env
var into each agent's container.
- `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" } }`).
@ -418,6 +410,15 @@ 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 capabilities). `Reminder` rows always carry `owner == self`. default permissions). `Reminder` rows always carry `owner == self`.
Per-variant fields: Per-variant fields:
@ -272,97 +272,83 @@ JetStream permission refusal, a TLS chain failure) rather than a summary.
Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
JSON-line protocol with their own result types; RFC 9457 is an HTTP format. JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
## Tool groups ## Permissions
A set of named `ToolGroup` values (`hive_sh4re::permissions::ToolGroup`) One set of named `Permission` values (`hive_sh4re::permissions::Permission`)
determines the MCP tool surface an agent receives, not a hardcoded decides what an agent may do: which MCP and built-in tools it gets, and which
binary flavor. host access it has.
| Group | Tools | | Permission | Grants |
|---|---| |---|---|
| `messaging` | `send`, `recv`, `ack_until` | | `messaging` | `send`, `recv`, `ack_until` |
| `meta` | `get_agent_meta` (`set_status` is always-on, see below) | | `meta` | `get_agent_meta` (`set_status` is always-on, see below) |
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` | | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
| `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. | | `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/tools/bash.md`. |
| `lifecycle` | none | | `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
| `forge` | none | | `manage_root_agent` | every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus `/applied` and `/meta` (ro) |
| `web_tools` | none (gates the Claude built-ins `WebFetch`/`WebSearch`, not an MCP tool) |
**Always-on tools** — `ToolGroup::ALWAYS_ON_TOOLS` exposes `set_status`, **Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of which `compact`, and `mark_todos_done` to every agent regardless of its
groups it holds. The operator dashboard depends on every agent permissions. The operator dashboard depends on every agent being able to
being able to report its status chip, and the server-side `SetStatus` handler report its status chip, and the server-side `SetStatus` handler has no
has no tool-group check (only length validation), so gating it would only permission check (only length validation), so gating it would only desync
desync the `--allowedTools` list from what the host actually accepts. the `--allowedTools` list from what the host actually accepts. Revoking
Revoking `meta` therefore drops `get_agent_meta` but never `set_status`. `meta` therefore drops `get_agent_meta` but never `set_status`.
`mark_todos_done` is here because producers push todos to an agent independent of `mark_todos_done` is here because producers push todos to an agent whether
whether it holds `inbox` — an agent without that group still needs a way to or not it holds `inbox`, and an agent without it still needs to clear them.
clear them.
**Config storage** — per-agent tool groups live in **Where they live** — in the swarm, not on the hive: the `permissions` field of
`/var/lib/hyperhive/meta/tool-groups.json` (hive-c0re-owned, committed to the the agent's key in the `agent-permissions` KV bucket
meta repo alongside `topology.json`). Format: `{ "alice": ["messaging", "meta", (`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone
"inbox", "lifecycle"], "bob": ["messaging", "meta", "inbox"] }`. An absent entry writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
means "use role default." Tool permissions are intentionally NOT configurable (`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
from `agent.nix` — that file changes through config PRs the manager opens, so (`permissions` in `POST /api/agents`). A missing key or an unset field means
letting it declare its own groups would let the manager grant itself any tool by the default (`Permission::default_for`): `messaging`, `meta`, `inbox`,
submitting a config commit, bypassing the operator gate. `execution`, plus `manage_root_agent` for `ruth`. `"permissions": []` grants
none.
**Setting groups** — the operator sets groups via the dashboard or **How a change applies** —
`hive-c0re::tool_groups::set_groups(name, groups)`. After a change - The agent's harness reads its own key every 10 seconds and builds every
`meta::sync_agents` commits the updated file; the next agent rebuild picks up turn's `--tools`, `--allowedTools` and MCP config from the last value, so a
the new `HIVE_TOOL_GROUPS` env var. Agents with no entry get no var. change reaches the next turn. An ACP agent's permission check reads it on
each request.
- The subagent daemon reads the key on each `start`/`continue`; a running
subagent keeps its tools.
- hive-c0re reads each of its agents' keys every 30 seconds
(`workers/swarm_permissions.rs`) into
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo.
nspawn fixes the bind mounts at container start, so when an agent's
`manage_root_agent` changes hive-c0re restarts it after its current turn.
- 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.
**Runtime resolution** — at session start the harness reads `HIVE_TOOL_GROUPS` Every reader keeps its last good value on a failed read.
(a comma-separated list of snake_case group names injected by the meta renderer
from `tool-groups.json`), logging and skipping unrecognised tokens. Falls back
to `ToolGroup::AGENT_DEFAULT` (`messaging`, `meta`, `inbox`, `execution`) when
the var is absent or empty.
**Updating the surface** — when you add a new `#[tool]` fn to `AgentServer` **Moving off the meta repo** — on its first boot with this code a hive copies
in `hive-agent-mcp/src/mcp/mod.rs`, add its name to the matching `ToolGroup::tools()` `meta/capabilities.json` and `meta/tool-groups.json` into the stored value,
slice in `hive-sh4re/src/permissions.rs`. That's the single source of truth; sends them to swarm-controller once as one list per agent (without
`mcp_config::allowed_mcp_tools` (in `hive-agent/src/mcp_config.rs`) reads it at `lifecycle` and `forge`, which gave no tools; the controller writes a key
session start. only where there is none, and only for agents that hive's wanted state
declares), and deletes
both files from the meta repo when the controller answers
(`workers/permissions_import.rs`). Until then hive-c0re reads no keys, and an
agent that reads its own key unset keeps the grant in its copy.
## Capabilities **Not configurable from `agent.nix`** — that file changes through config PRs
an agent can open, so letting it declare its own permissions would let an
agent grant itself any tool by submitting a config commit, bypassing the
operator gate.
Capabilities gate system-level access that goes beyond the MCP tool surface — **Adding to the set** — add a variant to `Permission` in
things an agent can *access*, not just *call*. Parallel to tool groups but `hive-sh4re/src/permissions.rs` and to `Permission::ALL`, and enforce it where
orthogonal: an agent can have a tool group that registers a tool AND a capability it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`)
that allows the underlying resource access. goes into the matching `Permission::tools()` slice: that's the single source
of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`)
| Capability | Effect | reads each turn.
|---|---|
| `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,9 +71,8 @@ Cheap — no build slot:
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step | | `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
| `DestroyBookkeeping` | the post-destroy tail — meta sync, drop the power intent, notify the manager, rescan, re-emit the tombstone. Same split rationale as `RebuildBookkeeping`/`Swap`. The destructive work is `PurgeState`'s | | `DestroyBookkeeping` | the post-destroy tail — meta sync, drop the power intent, notify the manager, rescan, re-emit the tombstone. Same split rationale as `RebuildBookkeeping`/`Swap`. The destructive work is `PurgeState`'s |
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles | | `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome | | `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload | | `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
| `WritePermFile` | commit `tool-groups.json` / `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 |
@ -90,7 +89,7 @@ Two further layers protect the meta repo across _windows_ that span multiple
- **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue - **The deploy window** (`Resource::MetaWindow`): a global, capacity-1 queue
resource declared by every node kind that mutates the meta repo — `MetaSync`, resource declared by every node kind that mutates the meta repo — `MetaSync`,
`MetaLock`, `WritePermFile` and `Provision`'s agent registration. Two meta `MetaLock` and `Provision`'s agent registration. Two meta
mutations can therefore never interleave, so no commit lands inside another mutations can therefore never interleave, so no commit lands inside another
node's window. It's a queue resource rather than a runtime mutex node's window. It's a queue resource rather than a runtime mutex
because a subtree root holds a resource across its whole subtree, which because a subtree root holds a resource across its whole subtree, which
@ -139,7 +138,6 @@ restart(a..): online a: [Signal→Drain→ if graceful] StopForUpdate(a) → Re
offline a: Reconcile(a) (nothing to stop; Reconcile converges to existing wanted) offline a: Reconcile(a) (nothing to stop; Reconcile converges to existing wanted)
start(a..): a: SetWanted(a,Up) → Reconcile(a) (down+stale ⇒ SetWanted(a,Up) → «rebuild subgraph») start(a..): a: SetWanted(a,Up) → Reconcile(a) (down+stale ⇒ SetWanted(a,Up) → «rebuild subgraph»)
spawn(a): [wanted=Up at deploy] Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a) spawn(a): [wanted=Up at deploy] Provision(a) → Create(a) → WriteDropin(a) → Reconcile(a)
perm-change(a): WritePermFile(a) → «rebuild subgraph»
meta-update(inp): MetaLock(inp) →(in-DAG) «rebuild subgraph» per affected agent meta-update(inp): MetaLock(inp) →(in-DAG) «rebuild subgraph» per affected agent
boot: (if any rev marker stale) MetaLock(hyperhive) →(in-DAG) «rebuild subgraph» per stale agent; boot: (if any rev marker stale) MetaLock(hyperhive) →(in-DAG) «rebuild subgraph» per stale agent;
plus Reconcile(a) for every drifted agent (all ONE DAG) plus Reconcile(a) for every drifted agent (all ONE DAG)
@ -215,7 +213,7 @@ resources are free. Resources:
(`SetWanted` is a store write, not a container op, but takes the lease anyway (`SetWanted` is a store write, not a container op, but takes the lease anyway
so a power-op DAG's intent write + reconcile is atomic — two racing ops can't so a power-op DAG's intent write + reconcile is atomic — two racing ops can't
clobber intent before either reconciles.) **Lease-exempt**: `MetaSync`, clobber intent before either reconciles.) **Lease-exempt**: `MetaSync`,
`Prebuild`, `Provision`, `MetaLock`, `WritePermFile` — they `Prebuild`, `Provision`, `MetaLock` — they
touch the store / meta, not the running container, which is exactly why a touch the store / meta, not the running container, which is exactly why a
stop can land while another DAG's prebuild is still building. Also exempt, for a different stop can land while another DAG's prebuild is still building. Also exempt, for a different
reason, are the rebuild subtree's own members (`StopForUpdate`, `Swap`, reason, are the rebuild subtree's own members (`StopForUpdate`, `Swap`,

View file

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

View file

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

View file

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

View file

@ -12,6 +12,10 @@ 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)
@ -66,6 +70,7 @@ Manage agents across the swarm
* `create` — Queue creation of a new agent on a hive in this swarm * `create` — Queue creation of a new agent on a hive in this swarm
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale * `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
* `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models * `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models
* `permissions` — Show or change the permissions the swarm grants an agent
@ -198,6 +203,77 @@ 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 server-side in the broker socket (which tool groups differences live in the agent's swarm permissions and the broker socket (which tools
and manager-surface calls each agent receives). and manager-surface calls each agent receives).
`agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`. `agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`.
@ -250,7 +250,7 @@ The rest lives alongside this page, in three topic files:
custom static files, connectivity overrides, claude plugins, cargo message custom static files, connectivity overrides, claude plugins, cargo message
filtering). filtering).
- **[mcp.md](mcp.md)** — the MCP tool surface claude sees: core tools, - **[mcp.md](mcp.md)** — the MCP tool surface claude sees: core tools,
privileged tool groups, self-wake, authoritative state, the tool envelope, permission-gated tools, self-wake, authoritative state, the tool envelope,
and the built-in tool allowlist. and the built-in tool allowlist.
Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages

View file

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

View file

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

View file

@ -20,7 +20,7 @@ from the dashboard tab strip.
- **← home back-link**: top-left of the chrome, links to the H0M3 hub - **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other. at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`, - **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
`◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only — `◆ BUILD L0GS ◆`. In-page tabs only —
the SYST3M panels live on the the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the live on their own pages too, all reachable from the
@ -77,19 +77,15 @@ H0M3 hub (served at
`/core.html`), with minimal chrome: a `← home` back-link and a single pane, `/core.html`), with minimal chrome: a `← home` back-link and a single pane,
**K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that **K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that
cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` `tombstones_changed`. (Rebuild queue and meta inputs are dashboard
(the latter two re-render the stale-perms sub-section when permission tabs — see below.)
data changes). (Rebuild queue and meta inputs are dashboard tabs — see
below.)
Per-container CPU, memory and disk load isn't shown in the hive UI. The Per-container CPU, memory and disk load isn't shown in the hive UI. The
same figures go out as OTEL metrics and appear in Grafana (see same figures go out as OTEL metrics and appear in Grafana (see
[observability](../scheduler/observability.md)). An agent's CPU and memory [observability](../scheduler/observability.md)). An agent's CPU and memory
caps live per agent in the swarm UI. caps live per agent in the swarm UI.
**K3PT ST4T3** — two sub-sections on one pane: **K3PT ST4T3** — agents with kept state and **no container** (size + age +
*Tombstones*: agents with kept state and **no container** (size + age +
claude-creds badge). One action: `PURG3` (wipes state + applied claude-creds badge). One action: `PURG3` (wipes state + applied
dirs; `POST /api/purge-tombstone/{name}`). dirs; `POST /api/purge-tombstone/{name}`).
@ -104,14 +100,6 @@ container is all hive-c0re can actually prove. Fixing it properly
needs a recorded destroy — **deferred** to the swarm-controller / needs a recorded destroy — **deferred** to the swarm-controller /
snapshot-storage rework where the problem changes shape. snapshot-storage rework where the problem changes shape.
*Stale permission entries*: agents with explicit 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
@ -228,94 +216,6 @@ badge with a ticking elapsed-time chip; expanding streams output via
(suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the (suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
entry with that id pre-expanded. entry with that id pre-expanded.
## P3RM1SS10NS tab
Per-agent 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
@ -754,46 +654,6 @@ below — some endpoints aren't in it yet.
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder. - `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery - `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries). failed (clears the failure state so the scheduler retries).
- `GET /api/tool-groups` — returns `{ groups: Vec<String>,
assignments: BTreeMap<String, Vec<String>>,
descriptions: BTreeMap<String, String> }`. `groups` is the
ordered list of all known tool-group names (drives the column
headers in the P3RM1SS10NS tab); `assignments` is the per-agent
override map (absent agents use the role default); `descriptions`
maps each group name to a short human-readable string surfaced as
a column-header tooltip on hover.
- `POST /api/tool-groups/{agent}` — body `{ groups: ["name", …] }`.
Writes the tool-group set for `{agent}` to
`/var/lib/hyperhive/meta/tool-groups.json` and queues a rebuild so
`HIVE_TOOL_GROUPS` takes effect. Agent name validated;
`guard_agent_name` applied.
- `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:
@ -841,18 +701,6 @@ payload):
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`). batch (`reminder_scheduler`).
- `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,10 +13,9 @@
<body class="core-shell"> <body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at <!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the /core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones) and the dashboard tab strip stays lean: kept state (tombstones). Rebuild
stale permission entries. Rebuild queue + meta inputs are tabs of queue + meta inputs are tabs of /dashboard.html. The section <div>
/dashboard.html. The section <div> ids (tombstones-section, id (tombstones-section) matches what core.js's renderers target. Hive
tombstones-stale-perms) match what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only no dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. --> control surface. -->
@ -26,11 +25,7 @@
</header> </header>
<main class="core-main"> <main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button). <!-- tombstones-section shows destroyed agents (purge button). -->
tombstones-stale-perms shows agents with explicit 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.
@ -38,7 +33,6 @@
<div id="tombstones-section"> <div id="tombstones-section">
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>
<div id="tombstones-stale-perms"></div>
</main> </main>
<script type="module" src="/static/core.js" defer></script> <script type="module" src="/static/core.js" defer></script>

View file

@ -1,6 +1,6 @@
// C0R3 page entry (/core.html). The host/coordinator surface, its own // C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state // standalone page so the dashboard tab strip stays lean: kept state
// (tombstones) and the stale permission entries. // (tombstones).
// //
// Rebuild queue + meta inputs are tabs of /dashboard.html, not here. This // Rebuild queue + meta inputs are tabs of /dashboard.html, not here. This
// page is its own esbuild bundle with its own runtime: it cold-loads // page is its own esbuild bundle with its own runtime: it cold-loads
@ -10,7 +10,7 @@
import { $, form, openStream, initServerWarnings } from "./common.js"; import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js"; import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js"; import { bindAsyncForms } from "@hive/shared/forms.js";
// ─── derived state (own copies; this bundle has its own runtime) ────────── // ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = []; let tombstonesState = [];
@ -95,92 +95,6 @@ function renderTombstones(s) {
root.append(ul); root.append(ul);
} }
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// Agents with explicit 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 });
@ -192,14 +106,6 @@ const SSE_HANDLERS = {
tombstonesState = (ev.tombstones || []).slice(); tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState }); renderTombstones({ tombstones: tombstonesState });
}, },
// Refresh the stale-perms sub-section when perm data changes (a ghost
// was cleared, or perms were saved for an agent whose name collides).
capabilities_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
}; };
// ─── boot ───────────────────────────────────────────────────────────────── // ─── boot ─────────────────────────────────────────────────────────────────
@ -226,17 +132,13 @@ async function init() {
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
await refreshState(); await refreshState();
fetchAndRenderStalePerms();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all // kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// 13 wire kinds down to the 3 this page acts on. This page was one // to the kinds this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this // of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream- // (subscription discipline, part 1 of the dashboard-event-stream-
// split issue). // split issue).
const es = openStream( const es = openStream("/api/dashboard/stream?kinds=tombstones_changed");
"/api/dashboard/stream?kinds=tombstones_changed," +
"capabilities_changed,tool_groups_changed",
);
if (es) { if (es) {
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;

View file

@ -566,155 +566,6 @@ 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,18 +71,6 @@
> >
<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
@ -205,75 +193,6 @@
<div id="build-list"><p class="meta">loading…</p></div> <div id="build-list"><p class="meta">loading…</p></div>
</section> </section>
<!-- P3RM1SS10NS: per-agent 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,9 +41,7 @@
<span class="home-tile-icon" aria-hidden="true">🖥</span> <span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span> <span class="home-tile-label">Dashboard</span>
</span> </span>
<span class="home-tile-desc" <span class="home-tile-desc">containers · builds</span>
>containers · builds · permissions</span
>
</a> </a>
<a class="home-tile" href="/flow.html"> <a class="home-tile" href="/flow.html">
@ -69,7 +67,7 @@
<span class="home-tile-icon" aria-hidden="true">⬡</span> <span class="home-tile-icon" aria-hidden="true">⬡</span>
<span class="home-tile-label">Core</span> <span class="home-tile-label">Core</span>
</span> </span>
<span class="home-tile-desc">kept state · stale permissions</span> <span class="home-tile-desc">kept state</span>
</a> </a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by <!-- API tile: the OpenAPI spec + Swagger UI are always served by

View file

@ -1,468 +0,0 @@
// 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, the // The roster is read by nearly every tab (the container tree and the
// capabilities + tool-group matrices, and the selection bar), so // selection bar), so
// it lives here as a single source of truth that each domain module // it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything // imports rather than threading through call signatures. Everything
// else in the dashboard is domain-local and lives with its own module. // else in the dashboard is domain-local and lives with its own module.

View file

@ -199,42 +199,3 @@
/* Remove the now-dead `.rqe-step` rule (the `step` sub-step label was /* Remove the now-dead `.rqe-step` rule (the `step` sub-step label was
retired from the wire in `feat(job_queue): retire the now-off-wire step retired from the wire in `feat(job_queue): retire the now-off-wire step
sub-step label` — the rule has had no JS referents since then). */ sub-step label` — the rule has had no JS referents since then). */
/* ─── K3PT ST4T3 › stale permission entries sub-section ────────────────────
Agents with explicit 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,13 +16,6 @@ import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js"; import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js"; import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js"; import { containersState, syncContainersFromSnapshot } from "./state.js";
import {
applyCapabilitiesChanged,
applyToolGroupsChanged,
fetchAndRenderCapabilities,
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import { import {
initJobqRollup, initJobqRollup,
syncTransientsFromSnapshot, syncTransientsFromSnapshot,
@ -61,12 +54,7 @@ window.marked = marked;
// Sections whose innerHTML gets blown away on each refresh. If the // Sections whose innerHTML gets blown away on each refresh. If the
// operator is typing in one of them, skip the refresh — the next // operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur. // tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [ const MANAGED_SECTION_IDS = ["containers-section", "inbox-section"];
"containers-section",
"inbox-section",
"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
@ -210,8 +198,6 @@ 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
@ -222,13 +208,12 @@ window.marked = marked;
// fallback when SharedWorker isn't supported. // fallback when SharedWorker isn't supported.
// //
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries // kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
// only the 9 wire kinds this page acts on. // only the 7 wire kinds this page acts on.
const es = openStream( const es = openStream(
"/api/dashboard/stream?kinds=" + "/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," + "transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," + "container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running," + "meta_inputs_changed,meta_update_running",
"capabilities_changed,tool_groups_changed",
); );
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;
@ -260,8 +245,8 @@ window.marked = marked;
})(); })();
// ─── tab routing ─────────────────────────────────────────────────────── // ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / // Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs`
// `#permissions` activate the matching pane on the dashboard. Empty // activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs — // hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from // they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only // the H0M3 hub. Tab routing only
@ -278,15 +263,6 @@ window.marked = marked;
// visibility (bar only belongs on 4G3NTS where agent cards live). // visibility (bar only belongs on 4G3NTS where agent cards live).
document.body.dataset.activeTab = target; document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values())); renderSelectionBar(Array.from(containersState.values()));
// Permissions 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,6 +25,9 @@
// Its name field, hive select, and submit button all come from the // Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than // shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why. // page-scoped input/button chrome — see that kit's own comments for why.
//
// Permissions: checkboxes from `GET /api/permissions`, on the default set;
// sent only when changed, so an untouched agent follows the default.
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact"; import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -44,6 +47,12 @@ import {
InferenceFields, InferenceFields,
type InferenceDraft, type InferenceDraft,
} from "../agent/detail/AgentInference.js"; } from "../agent/detail/AgentInference.js";
import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
import "./CreateAgentForm.css"; import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -59,6 +68,12 @@ interface CreateAgentResponse {
node_id: number; node_id: number;
} }
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState = type SubmitState =
| { status: "idle" } | { status: "idle" }
| { status: "submitting" } | { status: "submitting" }
@ -103,6 +118,12 @@ export function CreateAgentForm({
EMPTY_INFERENCE_DRAFT, EMPTY_INFERENCE_DRAFT,
); );
const inferenceProblem = draftProblem(inference); const inferenceProblem = draftProblem(inference);
// `null` until `/api/permissions` answers; `selected` starts on its default.
const [catalogue, setCatalogue] = useState<PermissionCatalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null,
);
const [selected, setSelected] = useState<string[]>([]);
// Loaded once, not re-fetched on submit: the roster changes rarely // Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one // enough (a nix-level swarm config change) that staleness within one
@ -124,6 +145,19 @@ export function CreateAgentForm({
})().catch((e: unknown) => setHivesError({ detail: String(e) })); })().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []); }, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data);
setSelected(data.default);
})().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
}, []);
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({ const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name, value: h.name,
label: h.name, label: h.name,
@ -146,6 +180,9 @@ export function CreateAgentForm({
hive, hive,
...(Object.keys(limits).length > 0 ? { limits } : {}), ...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}), ...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
}), }),
}); });
if (!r.ok) { if (!r.ok) {
@ -155,6 +192,7 @@ export function CreateAgentForm({
const data = (await r.json()) as CreateAgentResponse; const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id }); setResult({ status: "done", nodeId: data.node_id });
setName(""); setName("");
if (catalogue) setSelected(catalogue.default);
} catch (err) { } catch (err) {
setResult({ status: "error", problem: { detail: String(err) } }); setResult({ status: "error", problem: { detail: String(err) } });
} }
@ -176,6 +214,12 @@ export function CreateAgentForm({
problem={hivesError} problem={hivesError}
/> />
)} )}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
<form class="create-agent-form" onSubmit={submit}> <form class="create-agent-form" onSubmit={submit}>
<TextField <TextField
id="agent-name" id="agent-name"
@ -202,6 +246,19 @@ export function CreateAgentForm({
: "select a hive" : "select a hive"
} }
/> />
<FormField label="permissions" htmlFor="agent-permissions">
<div id="agent-permissions">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
/>
) : (
<span>loading…</span>
)}
</div>
</FormField>
<details class="create-agent-section"> <details class="create-agent-section">
<summary>resource caps</summary> <summary>resource caps</summary>
<div class="create-agent-section-body"> <div class="create-agent-section-body">
@ -230,6 +287,7 @@ export function CreateAgentForm({
disabled={ disabled={
result.status === "submitting" || result.status === "submitting" ||
!hivesReady || !hivesReady ||
!catalogue ||
inferenceProblem !== null inferenceProblem !== null
} }
> >

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -57,6 +57,7 @@ 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 (tool groups, - **`agent_config/`** — per-agent registries (permissions,
capabilities, resource limits, topology). 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

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

View file

@ -0,0 +1,148 @@
//! 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`, `tool-groups.json` and `capabilities.json`. //! `topology.json`.
//! //!
//! Format: a JSON object mapping agent name to an object with optional //! Format: a JSON object mapping agent name to an object with optional
//! `cpu_quota` / `memory_max` strings, passed verbatim to systemd's //! `cpu_quota` / `memory_max` strings, passed verbatim to systemd's

View file

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

View file

@ -9,11 +9,12 @@
//! dashboard's tree view. All of that is gone; what the file is //! dashboard's tree view. All of that is gone; what the file is
//! *for* now is the one thing that survived the removal — naming every //! *for* now is the one thing that survived the removal — naming every
//! agent, which is the set a //! agent, which is the set a
//! [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets //! [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets
//! bind-mounted ([`all_agents`]). //! bind-mounted ([`all_agents`]).
//! //!
//! Broader-than-your-own bind-mount grants are **not** stored here: they //! Broader-than-your-own bind-mount grants are **not** stored here: they
//! hang off the `ManageRootAgent` capability in `capabilities.json`. See //! hang off the `ManageRootAgent` permission the swarm grants
//! (`swarm_permissions`). See
//! `lifecycle::set_nspawn_flags`. //! `lifecycle::set_nspawn_flags`.
use std::collections::BTreeSet; use std::collections::BTreeSet;
@ -64,8 +65,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::Capability::ManageRootAgent`] holder gets /// a [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets
/// bind-mounted, and it is deliberately unfiltered: that capability means /// bind-mounted, and it is deliberately unfiltered: that permission 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,87 +552,6 @@ impl Coordinator {
}); });
} }
/// Best-effort synchronous snapshot of live container names (the
/// logical agent names from the last `nixos-container list` scan).
/// Returns `None` when the roster cache lock is momentarily
/// contended. The async `containers_snapshot` is the reliable path
/// for request handlers; this exists for the sync SSE snapshot emits.
#[must_use]
pub fn live_container_names_blocking(&self) -> Option<HashSet<String>> {
self.last_containers
.try_lock()
.ok()
.map(|m| m.keys().cloned().collect())
}
/// Emit a `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,7 +37,6 @@ use crate::lifecycle;
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"), (name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + 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"),
@ -56,7 +55,6 @@ mod health;
mod lifecycle_ops; mod lifecycle_ops;
mod meta_inputs; mod meta_inputs;
mod misc_api; mod misc_api;
pub(crate) mod permissions;
mod rebuild_queue; mod rebuild_queue;
mod state_files; mod state_files;
mod state_snapshot; mod state_snapshot;
@ -124,13 +122,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_logs_agent))
.routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::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))
@ -378,13 +369,6 @@ mod router_build_probe {
.routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_logs_agent))
.routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_full))
.routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::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

@ -1,567 +0,0 @@
//! 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,42 +141,6 @@ pub enum DashboardEvent {
/// (transients, container state), and a poll loop would be /// (transients, container state), and a poll loop would be
/// slower for the same information. /// slower for the same information.
RebuildQueueChanged { seq: u64 }, RebuildQueueChanged { seq: u64 },
/// Full snapshot of 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 {
@ -203,8 +167,6 @@ impl DashboardEvent {
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
} }
} }
} }
@ -279,22 +241,6 @@ 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,7 +120,6 @@ pub(super) async fn run_node(
// The payload rides the node and is destructured here, so the executor // The payload rides the node and is destructured here, so the executor
// takes it directly instead of re-matching the kind behind a `bail!` // takes it directly instead of re-matching the kind behind a `bail!`
// that could never fire. // that could never fire.
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
NodeKind::EmitRebuilt { ok, .. } => { NodeKind::EmitRebuilt { ok, .. } => {
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await; run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
Ok(()) Ok(())
@ -790,47 +789,6 @@ async fn run_write_dropin(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Write + commit the perm file(s) (fused under `META_LOCK` so the
/// working tree is never left dirty), then emit the P3RM1SS10NS-tab
/// snapshots so the dashboard reflects the new assignment.
async fn run_write_perm_file(
coord: &Arc<Coordinator>,
name: &str,
payload: &super::model::PermPayload,
) -> Result<()> {
use super::model::PermPayload;
// Runs under the deploy window (it declares `Resource::MetaWindow`): a
// perm commit landing inside another node's staged prepare→finalize
// window would sweep the staged deploy lock into its commit (the
// commits are also path-limited in meta.rs — belt and braces).
match payload {
PermPayload::ToolGroups { groups } => {
crate::meta::commit_tool_groups(name, groups)
.await
.with_context(|| format!("commit tool-groups for {name}"))?;
coord.emit_tool_groups_snapshot();
}
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, PermPayload, State}; pub use model::{NodeKind, State};
use resource::Resource; use resource::Resource;
/// A job under construction: `hive_jobq`'s builder over this queue's payload /// A job under construction: `hive_jobq`'s builder over this queue's payload

View file

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

View file

@ -20,7 +20,7 @@
//! `lifecycle::is_running` read), so [`super::power`] assembles them out of //! `lifecycle::is_running` read), so [`super::power`] assembles them out of
//! the primitives this module exports ([`rebuild_nodes`]). //! the primitives this module exports ([`rebuild_nodes`]).
use super::model::{NodeKind, PermPayload}; use super::model::NodeKind;
use super::resource::Resource; use super::resource::Resource;
use super::{Handle, JobBuilder}; use super::{Handle, JobBuilder};
@ -403,26 +403,6 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
.after_ok(last); .after_ok(last);
} }
/// Perm change: commit the JSON file(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,41 +1515,6 @@ fn destroy_container_must_not_suppress_crash_watch() {
); );
} }
#[test]
fn perm_change_shape_prefixes_rebuild_chain() {
let q = JobQueue::new(1);
insert(&q, |builder| {
templates::perm_change(
builder,
"agent-a",
PermPayload::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 copy of its memory cap, and the `write_dropins` verb //! drop-in, the agent's copies of its memory cap and stored permissions, and
//! that re-applies them. //! the `write_dropins` verb 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::Capability; use hive_sh4re::permissions::Permission;
use crate::coordinator::{AgentPaths, HiveEnv}; use crate::coordinator::{AgentPaths, HiveEnv};
@ -17,14 +17,16 @@ 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 copy of /// mounts etc.), the systemd resource-limits drop-in, the agent's copies of
/// its memory cap, and a daemon reload so all of it takes effect on the next /// its memory cap and stored permissions, and a daemon reload so all of it
/// unit (re)start. Idempotent — the job queue's `WriteDropin` node, also /// takes effect on the next unit (re)start. Idempotent — the job queue's
/// folded into every `Swap` (rebuild is the reconcile verb). /// `WriteDropin` node, also folded into every `Swap` (rebuild is the
/// 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(),
@ -381,22 +383,36 @@ fn bind_own_agent_dirs(agent_name: &str, notes_dir: &Path, binds: &mut Vec<BindM
} }
} }
/// Whether `agent_name` holds `ManageRootAgent` per the capabilities file /// Whether `agent_name` holds `ManageRootAgent` per the swarm permissions
/// at `caps_path`. An unreadable file denies: the error is logged and the /// stored at `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, caps_path: &Path) -> bool { fn holds_manage_root_agent(agent_name: &str, path: &Path) -> bool {
crate::capabilities::has_cap(caps_path, agent_name, Capability::ManageRootAgent).unwrap_or_else( crate::permissions::holds(path, agent_name, Permission::ManageRootAgent).unwrap_or_else(|e| {
|e| { tracing::error!(
tracing::error!( agent = %agent_name,
agent = %agent_name, path = %path.display(),
path = %caps_path.display(), error = ?e,
error = ?e, "swarm permissions unreadable — no ManageRootAgent mounts"
"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
@ -569,25 +585,25 @@ async fn set_nspawn_flags(
read_only: true, read_only: true,
}); });
// `ManageRootAgent` capability: mount *every* agent in the hive as a // `ManageRootAgent` permission: mount *every* agent in the hive as a
// virtual child. Enables recovery — the holder can update another // virtual child. Enables recovery — the holder can update another
// agent's config even when that agent is down. Also grants RO access // agent's config even when that agent is down. Also grants RO access
// to /applied and /meta. // to /applied and /meta.
// //
// ⚠️ "every agent" is the definition of this capability, not an // ⚠️ "every agent" is the definition of this permission, not an
// accident of how the set is computed. The grant used to hang off a // accident of how the set is computed. The grant used to hang off a
// `can_manage_top_level_agents` role and cover `top_level_agents()` // `can_manage_top_level_agents` role and cover `top_level_agents()`
// — i.e. `parent.is_none()` — which was "everything outside the // — i.e. `parent.is_none()` — which was "everything outside the
// hierarchy". With that hierarchy removed, every agent is // hierarchy". With that hierarchy removed, every agent is
// parentless, so that set *was* every agent anyway; the capability // parentless, so that set *was* every agent anyway; the permission
// now says so out loud instead of deriving it from a field that no // now says so out loud instead of deriving it from a field that no
// longer discriminates. // longer discriminates.
// //
// This is the *only* cross-agent mount left. Dropping the topology // This is the *only* cross-agent mount left. Dropping the topology
// parent field took with it the unconditional grant every // parent field took with it the unconditional grant every
// agent used to get over its own direct children — so an agent with // agent used to get over its own direct children — so an agent with
// no capability now sees its own dirs and nothing else. // no permission now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::capabilities::capabilities_path()) { if holds_manage_root_agent(agent_name, &crate::permissions::path()) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so // `agent_state_dir/state` and `config_bind_source` is shared, so
// binding the holder as its own virtual child reproduced the two // binding the holder as its own virtual child reproduced the two
@ -818,22 +834,30 @@ mod tests {
} }
#[test] #[test]
fn a_corrupt_capabilities_file_denies_without_failing() { fn corrupt_swarm_permissions_deny_without_failing() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capabilities.json"); let path = dir.path().join("swarm-agent-permissions.json");
let truncated = "{\n \"ruth\": [\"manage_root_ag"; let truncated = "{\n \"ruth\": {\"permissions\": [\"manage_root_ag";
std::fs::write(&path, truncated).expect("seed"); std::fs::write(&path, truncated).expect("seed");
assert!(!holds_manage_root_agent("ruth", &path)); assert!(!holds_manage_root_agent("ruth", &path));
assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes()); assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes());
} }
/// 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 a_readable_grant_is_honoured() { fn the_swarm_grant_or_the_default_decides_the_recovery_mounts() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capabilities.json"); let path = dir.path().join("swarm-agent-permissions.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::write_dropins; pub use host_config::{hand_permissions, 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::{capabilities, limits, resource_limits, tool_groups, topology}; pub(crate) use agent_config::{limits, permissions, resource_limits, topology};
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings}; pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
pub(crate) use stores::{broker, build_logs, db, power}; pub(crate) use stores::{broker, build_logs, db, power};
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets}; pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};
@ -277,6 +277,9 @@ 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,26 +45,14 @@ pub struct AgentSpec {
pub port: u16, pub port: u16,
} }
/// Stage every generated meta JSON file that exists: topology.json is /// Stage topology.json if it exists: it is regenerated by `reconcile`
/// regenerated by `reconcile` whenever the agent set changed; /// whenever the agent set changed. Without staging, an
/// tool-groups/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<()> {
for (path, name) in [ if crate::topology::topology_path().exists() {
(crate::topology::topology_path(), "topology.json"), git(dir, &["add", "topology.json"]).await?;
(crate::tool_groups::tool_groups_path(), "tool-groups.json"),
(
crate::capabilities::capabilities_path(),
"capabilities.json",
),
] {
if path.exists() {
git(dir, &["add", name]).await?;
}
} }
Ok(()) Ok(())
} }
@ -84,7 +72,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let dir = crate::paths::meta_root(); let dir = crate::paths::meta_root();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_hive_flake(hive, agents)?; let new_flake = render_hive_flake(hive, agents);
let flake_path = dir.join("flake.nix"); let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default(); let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists(); let initial = !dir.join(".git").exists();
@ -197,8 +185,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
// Only ever a REMOVAL now; see `is_embedded_ca_name`. // Only ever a REMOVAL now; see `is_embedded_ca_name`.
f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"), f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"),
"topology.json" => Some("topology"), "topology.json" => Some("topology"),
"capabilities.json" => Some("capabilities"),
"tool-groups.json" => Some("tool-groups"),
_ => None, _ => None,
}) })
.collect(); .collect();
@ -304,90 +290,35 @@ pub async fn lock_update_hyperhive() -> Result<()> {
git_commit_paths(&dir, "bump hyperhive", &["flake.lock"]).await git_commit_paths(&dir, "bump hyperhive", &["flake.lock"]).await
} }
/// Write the tool-groups file for `agent` and commit it atomically /// Delete the meta repo's permission files and commit the removal under
/// under `META_LOCK`. Ensures the JSON change is staged + committed /// `META_LOCK`. Their grants live in the swarm's `agent-permissions` bucket
/// before the next `sync_agents` runs, so the /// once `workers::permissions_import` has sent them.
/// 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
/// ///
/// Returns an error if writing either JSON file fails, a capability name /// Removing a file or a git stage/commit step fails.
/// is invalid (`set_caps`), or a git stage/commit step fails. pub async fn remove_permission_files() -> Result<()> {
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 parts: Vec<&str> = Vec::new(); let mut removed = Vec::new();
if let Some(groups) = groups { for file in crate::workers::permissions_import::META_FILES {
crate::tool_groups::set_groups(agent, groups)?; match std::fs::remove_file(dir.join(file)) {
if crate::tool_groups::tool_groups_path().exists() { Ok(()) => {}
git(&dir, &["add", "tool-groups.json"]).await?; Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {file}")),
} }
parts.push("tool-groups"); git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", file]).await?;
} // Only a tracked file has a removal to commit; naming an untracked
if let Some(caps) = caps { // one makes `git commit -- <path>` fail.
crate::capabilities::set_caps(agent, caps) if paths_dirty(&dir, &[file]).await? {
.map_err(|e| anyhow::anyhow!("set capabilities for {agent}: {e}"))?; removed.push(*file);
if crate::capabilities::capabilities_path().exists() {
git(&dir, &["add", "capabilities.json"]).await?;
} }
parts.push("capabilities");
} }
let paths = ["tool-groups.json", "capabilities.json"]; if !removed.is_empty() {
if paths_dirty(&dir, &paths).await? {
git_commit_paths( git_commit_paths(
&dir, &dir,
&format!("set {} for {agent}", parts.join(" + ")), "permissions moved to the swarm's agent-permissions",
&paths, &removed,
) )
.await?; .await?;
} }
@ -396,7 +327,7 @@ pub async fn commit_perms(
/// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`] /// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`]
/// renders it. /// renders it.
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> { fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> String {
render_flake( render_flake(
&hive.hyperhive_flake, &hive.hyperhive_flake,
&hive.hyperhive_docs_flake, &hive.hyperhive_docs_flake,
@ -423,7 +354,7 @@ fn render_flake(
operator_pronouns: &str, operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>, context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec], agents: &[AgentSpec],
) -> Result<String> { ) -> String {
render_flake_with_lookup( render_flake_with_lookup(
hyperhive_flake, hyperhive_flake,
docs_flake, docs_flake,
@ -826,7 +757,7 @@ fn render_flake_with_lookup<F>(
context_window_tokens: &std::collections::HashMap<String, u64>, context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec], agents: &[AgentSpec],
lookup: F, lookup: F,
) -> Result<String> ) -> String
where where
F: Fn(&str) -> Vec<&'static str>, F: Fn(&str) -> Vec<&'static str>,
{ {
@ -905,7 +836,7 @@ where
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\""); let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!( let _ = writeln!(
out, out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null }}:" " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
); );
out.push_str( out.push_str(
r#" let r#" let
@ -914,8 +845,6 @@ where
else hyperhive.nixosConfigurations.agent-base; else hyperhive.nixosConfigurations.agent-base;
input = inputs."agent-${name}"; input = inputs."agent-${name}";
service = "hive-agent"; service = "hive-agent";
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
in in
base.extendModules { base.extendModules {
modules = [ modules = [
@ -1092,7 +1021,7 @@ where
} }
out.push_str( out.push_str(
r" }; r" };
systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // { systemd.services.${service}.environment = {
HIVE_PORT = toString port; HIVE_PORT = toString port;
HIVE_LABEL = name; HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort; HIVE_DASHBOARD_PORT = toString dashboardPort;
@ -1136,49 +1065,18 @@ where
nixosConfigurations = { nixosConfigurations = {
"#, "#,
); );
// Propagated, never read as empty: an agent with no tool-groups entry
// renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails
// open for any agent whose explicit entry is narrower.
let tool_groups_map = crate::tool_groups::read()?;
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 = {}; toolGroups = {}; capabilities = {}; }};", " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; }};",
spec.name, spec.name,
spec.name, spec.name,
if spec.is_manager { "true" } else { "false" }, if spec.is_manager { "true" } else { "false" },
spec.port, spec.port,
tool_groups_attr,
capabilities_attr,
); );
} }
out.push_str(" };\n };\n}\n"); out.push_str(" };\n };\n}\n");
Ok(out) out
} }
/// Return the list of file names that are currently staged (index differs /// Return the list of file names that are currently staged (index differs
@ -1263,7 +1161,7 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> {
/// Path-limited commit: commits ONLY the given paths, so unrelated /// Path-limited commit: commits ONLY the given paths, so unrelated
/// staged content can never be swept into someone else's commit. Every targeted /// staged content can never be swept into someone else's commit. Every targeted
/// meta commit (perm files, topology, lock bumps) goes through this; /// meta commit (limits, topology, lock bumps) goes through this;
/// only `sync_agents` uses the bare [`git_commit`], because its /// only `sync_agents` uses the bare [`git_commit`], because its
/// staged set *is* its intentional commit set. /// staged set *is* its intentional commit set.
async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> { async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> {
@ -1354,29 +1252,35 @@ mod tests {
git(dir, &["init", "--initial-branch=main"]) git(dir, &["init", "--initial-branch=main"])
.await .await
.expect("git init"); .expect("git init");
std::fs::write(dir.join("tool-groups.json"), "{}").expect("write"); std::fs::write(dir.join("resource-limits.json"), "{}").expect("write");
std::fs::write(dir.join("flake.lock"), "v1").expect("write"); std::fs::write(dir.join("flake.lock"), "v1").expect("write");
git(dir, &["add", "-A"]).await.expect("add"); git(dir, &["add", "-A"]).await.expect("add");
git_commit(dir, "seed").await.expect("seed commit"); git_commit(dir, "seed").await.expect("seed commit");
// Something stages a new lock (uncommitted)… // Something stages a new lock (uncommitted)…
std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write"); std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write");
git(dir, &["add", "flake.lock"]).await.expect("stage lock"); git(dir, &["add", "flake.lock"]).await.expect("stage lock");
// …and a perm change commits, path-limited. // …and a limits change commits, path-limited.
std::fs::write(dir.join("tool-groups.json"), r#"{"alice":[]}"#).expect("write"); std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write");
git(dir, &["add", "tool-groups.json"]).await.expect("add"); git(dir, &["add", "resource-limits.json"])
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
.await .await
.expect("path-limited commit"); .expect("add");
// The perm file is committed; the staged lock is not. git_commit_paths(
dir,
"set resource limits for alice",
&["resource-limits.json"],
)
.await
.expect("path-limited commit");
// The limits file is committed; the staged lock is not.
assert!( assert!(
!paths_dirty(dir, &["tool-groups.json"]) !paths_dirty(dir, &["resource-limits.json"])
.await .await
.expect("check"), .expect("check"),
"perm file must be committed" "limits file must be committed"
); );
assert!( assert!(
paths_dirty(dir, &["flake.lock"]).await.expect("check"), paths_dirty(dir, &["flake.lock"]).await.expect("check"),
"staged deploy lock must survive the perm commit" "staged deploy lock must survive the limits commit"
); );
} }
@ -1413,8 +1317,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
// nixpkgs is a top-level input with an explicit URL; hyperhive // nixpkgs is a top-level input with an explicit URL; hyperhive
// follows it. // follows it.
assert!( assert!(
@ -1460,8 +1363,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
assert!( assert!(
!out.contains("hyperhive-docs"), !out.contains("hyperhive-docs"),
"no docs input/source when docs_flake is empty:\n{out}" "no docs input/source when docs_flake is empty:\n{out}"
@ -1479,8 +1381,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
assert!( assert!(
out.contains(r#"networking.hostName = "h-${name}";"#), out.contains(r#"networking.hostName = "h-${name}";"#),
"an agent container with no hostname of its own is called `nixos`, \ "an agent container with no hostname of its own is called `nixos`, \
@ -1520,8 +1421,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
assert!( assert!(
out.contains( out.contains(
"services.hyperhive.agent.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";" "services.hyperhive.agent.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";"
@ -1549,8 +1449,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
assert!( assert!(
!out.contains("claudeCodePath"), !out.contains("claudeCodePath"),
"no claude assignment when unpinned:\n{out}" "no claude assignment when unpinned:\n{out}"
@ -1570,8 +1469,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
assert!( assert!(
out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""),
"expected fallback follows:\n{out}" "expected fallback follows:\n{out}"
@ -1606,8 +1504,7 @@ mod tests {
sample_spec("dmatrix", false, 9003), sample_spec("dmatrix", false, 9003),
], ],
lookup, lookup,
) );
.expect("render flake");
// bitburner declares nixpkgs → follows emitted. // bitburner declares nixpkgs → follows emitted.
assert!( assert!(
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""), out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
@ -1636,8 +1533,7 @@ mod tests {
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
|_| Vec::new(), |_| Vec::new(),
) );
.expect("render flake");
// No agent-side follows when the lookup reports nothing // No agent-side follows when the lookup reports nothing
// declared — protects agents whose flake.lock can't be read // declared — protects agents whose flake.lock can't be read
// (missing / unparsable) from being broken by a follows on a // (missing / unparsable) from being broken by a follows on a
@ -1674,8 +1570,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
unsafe { unsafe {
std::env::remove_var("HIVE_FORGE_URL"); std::env::remove_var("HIVE_FORGE_URL");
} }
@ -1721,7 +1616,6 @@ mod tests {
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) )
.expect("render flake")
}; };
let alpha = render_on("alpha-hive", "alpha-hive.example.test"); let alpha = render_on("alpha-hive", "alpha-hive.example.test");
let beta = render_on("beta-hive", "beta-hive.example.test"); let beta = render_on("beta-hive", "beta-hive.example.test");
@ -1901,8 +1795,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
unsafe { unsafe {
std::env::remove_var("HIVE_FORGE_URL"); std::env::remove_var("HIVE_FORGE_URL");
std::env::remove_var("HIVE_MATRIX_URL"); std::env::remove_var("HIVE_MATRIX_URL");
@ -1938,8 +1831,7 @@ mod tests {
"she/her", "she/her",
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) );
.expect("render flake");
let want = format!( let want = format!(
"agent-alice.url = \"git+file://{}\"", "agent-alice.url = \"git+file://{}\"",
crate::paths::applied_dir("alice").display() crate::paths::applied_dir("alice").display()
@ -1984,7 +1876,6 @@ mod tests {
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) )
.expect("render flake")
}; };
// A leftover peer-CA file + the env var that names it. Both must be // A leftover peer-CA file + the env var that names it. Both must be
@ -2077,7 +1968,6 @@ mod tests {
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) )
.expect("render flake")
}; };
unsafe { unsafe {
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
@ -2152,7 +2042,6 @@ mod tests {
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)], &[sample_spec("alice", false, 9001)],
) )
.expect("render flake")
}; };
unsafe { unsafe {
std::env::remove_var("HYPERHIVE_GITHUB_DISABLED"); std::env::remove_var("HYPERHIVE_GITHUB_DISABLED");
@ -2184,8 +2073,8 @@ mod tests {
..HiveEnv::default() ..HiveEnv::default()
}; };
let agents = [sample_spec("alice", false, 9001)]; let agents = [sample_spec("alice", false, 9001)];
let small = render_hive_flake(&hive("4G"), &agents).expect("render 4G"); let small = render_hive_flake(&hive("4G"), &agents);
let large = render_hive_flake(&hive("16G"), &agents).expect("render 16G"); let large = render_hive_flake(&hive("16G"), &agents);
assert_eq!( assert_eq!(
small, large, small, large,
"the rendered flake must not depend on the memory cap" "the rendered flake must not depend on the memory cap"

View file

@ -10,10 +10,11 @@
//! `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.** Ruth's tool groups were backfilled here on //! - **Create-time setup.** A thing that is true from birth is set where
//! every boot; they are now seeded where she is created //! the agent is created, not re-asserted each morning. Ruth's
//! (`workers::auto_update::ensure_root_agent`). A thing that is true //! permissions need neither: with no key in the swarm's
//! from birth does not need re-asserting each morning. //! `agent-permissions` bucket she holds the code-constant default
//! (`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 capabilities / /// ("ruth"), and ruth's reach comes entirely from the permissions it
/// tool-groups it holds, identical to connecting on a per-agent socket — ruth /// holds, identical to connecting on a per-agent socket — ruth
/// uses the standard per-agent runtime dir + socket, with no dedicated helpers. /// uses the standard per-agent runtime dir + socket, with no dedicated helpers.
pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> { pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;

View file

@ -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(),
coord, std::sync::Arc::clone(&coord),
hive.clone(), hive.clone(),
shutdown.clone(), shutdown.clone(),
)); ));
@ -109,6 +109,14 @@ 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,7 +23,6 @@ use anyhow::Result;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
use crate::tool_groups;
/// Resolve the current rev of `hyperhive_flake`. For a path on disk we /// Resolve the current rev of `hyperhive_flake`. For a path on disk we
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/... /// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/...
@ -124,10 +123,6 @@ 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!(
@ -204,7 +199,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
let hive = coord.hive_env(); let hive = coord.hive_env();
let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?; let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?;
lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?; lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?;
seed_manager_tool_groups();
if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) { if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) {
tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed"); tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed");
} }
@ -216,109 +210,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
Ok(()) Ok(())
} }
/// Give ruth her privileged tool groups on the one path that creates her.
///
/// `effective_tool_groups()` has no manager-flavour fallback, so an agent
/// with no entry in `tool-groups.json` is an agent with no privileged
/// tools. Ruth needs hers from her first turn, and this is the only place
/// she is brought into existence — so it is written once, here, rather
/// than re-checked on every hive-c0re boot.
///
/// Skips a name that already has an entry: a destroy+recreate under the
/// same name must not silently reset an operator's chosen group set back
/// to the default. An unreadable file is logged and left alone: ruth has
/// already been spawned, and seeding stays best-effort like the write
/// below.
fn seed_manager_tool_groups() {
match tool_groups::groups_for(MANAGER_NAME) {
Ok(groups) if groups.is_empty() => {}
Ok(_) => {
tracing::debug!("manager tool groups already set — leaving as-is");
return;
}
Err(e) => {
tracing::warn!(
error = ?e,
"tool-groups file unreadable — not seeding ruth's tool groups"
);
return;
}
}
let all_groups: Vec<String> = hive_sh4re::permissions::ToolGroup::MANAGER_DEFAULT
.iter()
.map(|g| <&str>::from(*g).to_owned())
.collect();
match tool_groups::set_groups(MANAGER_NAME, &all_groups) {
Ok(()) => tracing::info!("seeded ruth's tool groups to MANAGER_DEFAULT (all groups)"),
Err(e) => tracing::warn!(
error = ?e,
"failed to seed ruth's tool groups — she will start without privileged tools"
),
}
}
/// 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
@ -572,10 +463,7 @@ fn submit_boot_tree(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{BootAction, MANAGER_NAME, RootAgentPlan, boot_action, plan_root_agent};
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;
@ -604,45 +492,6 @@ 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 read. //! swarm resource-cap and permissions reads.
//! 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,5 +10,7 @@ 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

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

@ -0,0 +1,317 @@
//! 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 (`Source`, `State`, `PermPayload`, `NodeId`) is protocol-local. The typed needs (`State`, `NodeId`) is protocol-local. The typed
`DagView`/`NodeView` projection that used to live there is gone — the queue is `DagView`/`NodeView` projection that used to live there is gone — the queue is
served as a generic graph (`hive-jobq-wire`), not as a second hand-written served as a generic graph (`hive-jobq-wire`), not as a second hand-written
view. view.

View file

@ -1,6 +1,5 @@
//! Vocabulary hive-c0re's job queue shares with its clients: what a //! Vocabulary hive-c0re's job queue shares with its clients: the
//! permission change carries ([`PermPayload`]) and the scheduler's //! scheduler's lifecycle [`State`].
//! lifecycle [`State`].
//! //!
//! A `Source` enum lived here too — where a job came from, rendered as the //! A `Source` enum lived here too — where a job came from, rendered as the
//! "why" chip. It was a field on the DAG container, and it went with it: a //! "why" chip. It was a field on the DAG container, and it went with it: a
@ -15,27 +14,8 @@
//! scheduling) live in `hive-c0re::job_queue`. //! scheduling) live in `hive-c0re::job_queue`.
//! Semantics: `docs/scheduler/coordinator.md::Job queue`. //! Semantics: `docs/scheduler/coordinator.md::Job queue`.
use serde::{Deserialize, Serialize};
pub use hive_jobq::State; pub use hive_jobq::State;
/// Kind-specific payload for `Template::PermChange` DAGs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PermPayload {
/// Set the tool groups for one agent (`tool-groups.json`).
ToolGroups { groups: Vec<String> },
/// 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,7 +16,3 @@ serde.workspace = true
# `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows. # `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows.
serde_json.workspace = true serde_json.workspace = true
strum.workspace = true strum.workspace = true
# Facade only, for the one warn in `permissions::ToolGroup::parse_list`: an
# unknown tool-group name is skipped rather than fatal, so the log line is the
# only trace it leaves.
tracing.workspace = true

View file

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

View file

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

View file

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

View file

@ -1,11 +1,13 @@
//! Reads the subagent settings the swarm sets for this agent. //! Reads the subagent settings and permissions the swarm sets for this agent.
//! //!
//! swarm-controller keeps the agent's settings under its key in the //! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list //! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
//! a `start` or `continue` naming a `model` is checked against, in place of //! a `start` or `continue` naming a `model` is checked against, in place of
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its //! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
//! `max_subagents`, when set, caps how many subagents run at once //! `max_subagents`, when set, caps how many subagents run at once
//! (`crate::session::State::reserve_under`). //! (`crate::session::State::reserve_under`). The agent's permissions, under
//! its key in `agent-permissions`, decide a new subagent's tools
//! ([`permissions_now`]).
//! //!
//! **Read on every `start` and `continue`**, never cached, with the one subject the //! **Read on every `start` and `continue`**, never cached, with the one subject the
//! agent's own queue credential is granted in that bucket //! agent's own queue credential is granted in that bucket
@ -18,15 +20,35 @@
//! [`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);
@ -126,6 +148,44 @@ 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.
const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME"; pub(crate) 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` tool group. Passed as # asks, and the harness answers per the `web_tools` permission. 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,17 +382,7 @@ in
# HIVE_AGENT_SOCKET (agent-service.nix). # HIVE_AGENT_SOCKET (agent-service.nix).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info"; RUST_LOG = "info";
# This daemon resolves each subagent's `--tools` from the same # The harness's runtime selection, forwarded from its unit, so an ACP
# tool groups the harness resolves its own session from, so that a
# subagent's built-in tools are its parent agent's and never wider.
# The meta renderer writes this var onto the harness unit alone, so
# forward it from there rather than re-deriving it: without it the
# daemon falls back to the default groups and an agent granted
# `web_tools` would spawn subagents that quietly lack web access.
# `null` when the agent has no groups declared, which systemd drops
# — the same "absent" the harness itself would see.
HIVE_TOOL_GROUPS = config.systemd.services.hive-agent.environment.HIVE_TOOL_GROUPS or null;
# The harness's runtime selection, forwarded the same way, so an ACP
# agent's subagents run on its ACP agent. All five are absent on a # agent's subagents run on its ACP agent. All five are absent on a
# claude agent, which the daemon reads as claude. # claude agent, which the daemon reads as claude.
HIVE_RUNTIME = config.systemd.services.hive-agent.environment.HIVE_RUNTIME or null; HIVE_RUNTIME = config.systemd.services.hive-agent.environment.HIVE_RUNTIME or null;

View file

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

View file

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

View file

@ -66,6 +66,9 @@ 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).
@ -102,6 +105,7 @@ 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

@ -0,0 +1,737 @@
//! `/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,6 +47,7 @@ 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;
@ -1409,11 +1410,12 @@ 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 has to keep working. Sequenced rather than spawned so // its credential and permissions have to keep working. Sequenced rather
// the revocation attempt is finished — and its outcome logged — by the // than spawned so both attempts are finished — and their outcomes
// time the operator's call returns. // logged — by the 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)))
} }
@ -1741,6 +1743,11 @@ struct CreateAgentRequest {
/// /api/agents/{name}/inference` takes them. Absent as `limits`. /// /api/agents/{name}/inference` takes them. Absent as `limits`.
#[serde(default)] #[serde(default)]
inference: Option<inference::InferenceSettings>, inference: Option<inference::InferenceSettings>,
/// The agent's permissions, stored before the job is queued. Left out, the
/// agent holds the default set.
#[serde(default)]
#[schema(value_type = Option<Vec<String>>)]
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
} }
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or /// Where the queued job landed — a caller polls `/api/jobq/graph` (or
@ -1794,10 +1801,10 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest, request_body = CreateAgentRequest,
responses( responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse), (status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, or `limits` or `inference` holds a value the matching `PUT` refuses (problem+json)", body = String), (status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, or a permission is listed twice (problem+json)", body = String),
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String), (status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, or an `effort` that is not one of the five levels"), (status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits` or `inference` is set and cannot be stored (problem+json)", body = String), (status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference` or `permissions` is set and cannot be stored (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String), (status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
), ),
tag = "agents" tag = "agents"
@ -1812,15 +1819,7 @@ async fn create_agent(
// Settings are checked before anything is created, so a bad value stops // Settings are checked before anything is created, so a bad value stops
// the request rather than leaving an agent without them. // the request rather than leaving an agent without them.
let limits = req let settings = CreateSettings::parse(req.limits, req.inference, req.permissions)
.limits
.map(agent_limits::Caps::into_stored)
.transpose()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
let inference = req
.inference
.map(inference::InferenceSettings::into_stored)
.transpose()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?; .map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
// Two checks, and the second is the one that makes the field worth // Two checks, and the second is the one that makes the field worth
@ -1884,7 +1883,7 @@ async fn create_agent(
} }
// Checked after the roster, so a mistyped hive stays a 400 while the queue // Checked after the roster, so a mistyped hive stays a 400 while the queue
// is down. // is down.
let settings_queue = if limits.is_some() || inference.is_some() { let settings_queue = if settings.any() {
Some(schedules::queue(&state).map_err(schedules::refused)?) Some(schedules::queue(&state).map_err(schedules::refused)?)
} else { } else {
None None
@ -1915,7 +1914,14 @@ async fn create_agent(
// settings behind for the name. // settings behind for the name.
let stored = match &settings_queue { let stored = match &settings_queue {
Some(client) => { Some(client) => {
StoredSettings::store(client, &agent, limits.as_ref(), inference.as_ref()).await? StoredSettings::store(
client,
&agent,
settings.limits.as_ref(),
settings.inference.as_ref(),
settings.permissions.as_ref(),
)
.await?
} }
None => StoredSettings::default(), None => StoredSettings::default(),
}; };
@ -1947,27 +1953,67 @@ async fn create_agent(
})) }))
} }
/// The settings a `POST /api/agents` body gives, as they are stored.
struct CreateSettings {
limits: Option<swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
}
impl CreateSettings {
/// Each setting given, or why its `PUT` would refuse the first bad one.
fn parse(
limits: Option<agent_limits::Caps>,
inference: Option<inference::InferenceSettings>,
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
) -> Result<Self, String> {
Ok(Self {
limits: limits.map(agent_limits::Caps::into_stored).transpose()?,
inference: inference
.map(inference::InferenceSettings::into_stored)
.transpose()?,
permissions: permissions.map(agent_permissions::at_create).transpose()?,
})
}
/// Whether any setting is given.
fn any(&self) -> bool {
self.limits.is_some() || self.inference.is_some() || self.permissions.is_some()
}
}
/// What `create_agent` replaced in the settings stores, to put back if the /// What `create_agent` replaced in the settings stores, to put back if the
/// creation is refused after the write. /// creation is refused after the write.
#[derive(Default)] #[derive(Default)]
struct StoredSettings { struct StoredSettings {
limits: agent_limits::Replaced, limits: agent_limits::Replaced,
inference: inference::Replaced, inference: inference::Replaced,
permissions: agent_permissions::Replaced,
} }
impl StoredSettings { impl StoredSettings {
/// Store whichever of `limits` and `inference` are given. A failure on /// Store whichever of `limits`, `permissions` and `inference` are given, in
/// the second puts back the first. /// that order. A failure on one puts back the ones stored before it.
async fn store( async fn store(
client: &async_nats::Client, client: &async_nats::Client,
agent: &str, agent: &str,
limits: Option<&swarm_queue_client::agent_limits::AgentCaps>, limits: Option<&swarm_queue_client::agent_limits::AgentCaps>,
inference: Option<&swarm_queue_client::agent_inference::AgentInference>, inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
) -> Result<Self, problem_details::ProblemDetails> { ) -> Result<Self, problem_details::ProblemDetails> {
let mut stored = Self::default(); let mut stored = Self::default();
if let Some(limits) = limits { if let Some(limits) = limits {
stored.limits = agent_limits::store(client, agent, limits).await?; stored.limits = agent_limits::store(client, agent, limits).await?;
} }
if let Some(permissions) = permissions {
match agent_permissions::store(client, agent, permissions).await {
Ok(replaced) => stored.permissions = replaced,
Err(e) => {
stored.roll_back(client, agent).await;
return Err(e);
}
}
}
if let Some(inference) = inference { if let Some(inference) = inference {
match inference::store(client, agent, inference).await { match inference::store(client, agent, inference).await {
Ok(replaced) => stored.inference = replaced, Ok(replaced) => stored.inference = replaced,
@ -1983,6 +2029,7 @@ impl StoredSettings {
async fn roll_back(self, client: &async_nats::Client, agent: &str) { async fn roll_back(self, client: &async_nats::Client, agent: &str) {
agent_limits::restore(client, agent, self.limits).await; agent_limits::restore(client, agent, self.limits).await;
inference::restore(client, agent, self.inference).await; inference::restore(client, agent, self.inference).await;
agent_permissions::restore(client, agent, self.permissions).await;
} }
} }
@ -2877,11 +2924,22 @@ 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. /// schedule keys, and the permissions bucket and its import.
/// 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
@ -3493,6 +3551,12 @@ 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))
@ -3754,6 +3818,7 @@ mod tests {
hive: "pr1maa".to_owned(), hive: "pr1maa".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -3794,6 +3859,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -3956,6 +4022,7 @@ mod tests {
agent, agent,
Some(&caps(r#"{"cpu_quota":"400%"}"#)), Some(&caps(r#"{"cpu_quota":"400%"}"#)),
Some(&inference), Some(&inference),
None,
) )
.await; .await;
assert!(stored.is_err(), "{agent}: the inference write must fail"); assert!(stored.is_err(), "{agent}: the inference write must fail");
@ -3974,6 +4041,71 @@ 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]
@ -4027,6 +4159,7 @@ mod tests {
hive: hive.to_owned(), hive: hive.to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5149,6 +5282,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5202,6 +5336,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5251,6 +5386,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5308,6 +5444,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await
@ -5512,6 +5649,7 @@ mod tests {
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None, limits: None,
inference: None, inference: None,
permissions: None,
}), }),
) )
.await .await

View file

@ -229,7 +229,9 @@ 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)),
responses: None, // The answer to a hive's permissions import. NATS lets it
// 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) {
@ -307,7 +309,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, and its own reply inbox. /// to swarm-ui, every hive's permissions import, 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
@ -318,6 +320,7 @@ 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),
] ]
} }
@ -561,6 +564,17 @@ 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()
@ -837,6 +851,76 @@ 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
@ -1356,7 +1440,6 @@ 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,6 +50,11 @@ 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
@ -58,6 +63,7 @@ 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

@ -0,0 +1,346 @@
//! 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,6 +221,12 @@ 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,6 +29,7 @@ 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;
@ -290,6 +291,45 @@ 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)]
@ -602,6 +642,9 @@ 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),
@ -671,6 +714,16 @@ 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(

123
swarmctl/src/permissions.rs Normal file
View file

@ -0,0 +1,123 @@
//! `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":[]}"#
);
}
}