docs(agent-hierarchy): restructure audit doc into current/planned, trim internals

This commit is contained in:
iris 2026-08-15 11:47:26 +02:00 committed by mara
commit 66442d41ff

View file

@ -1,11 +1,13 @@
# Agent hierarchy & privileges # Agent hierarchy & privileges
Design + audit doc for the agent-privileges + tree-shape milestone Every agent has a place in an operator-editable parent/child tree, used
(the [issue tree](http://localhost:3000/hyperhive/hyperhive/issues/361)). to scope which agents can manage which others. This doc covers how the
The implementation lands in pieces; this doc tracks what's done, what's tree is stored and edited today, the rules that are meant to run on top
planned, and what currently special-cases the manager. of it once enforcement is finished, and where the manager still gets
special-cased in the meantime. Tracking issue:
[hyperhive#361](http://localhost:3000/hyperhive/hyperhive/issues/361).
## Current state (as of this PR) ## Where the tree lives
Topology lives in the hive-c0re-owned **meta repo**, alongside Topology lives in the hive-c0re-owned **meta repo**, alongside
`flake.nix`, at `/var/lib/hyperhive/meta/topology.json`: `flake.nix`, at `/var/lib/hyperhive/meta/topology.json`:
@ -18,45 +20,35 @@ Topology lives in the hive-c0re-owned **meta repo**, alongside
} }
``` ```
`null` = root-level agent. New agents **default to root** (`null` parent) — `null` = root-level agent. New agents **default to root** — there is no
there is no structural manager that everything hangs under. Hierarchy is structural manager that everything hangs under. Hierarchy is built
built explicitly: an agent that requests a sub-agent gets a explicitly: an agent that requests a sub-agent gets a
requester-as-parent edge written at its `init_config` approval (so `bob` requester-as-parent edge written at its `init_config` approval (so
above was spawned by `alice`), and the operator can reparent any agent. The `bob` above was spawned by `alice`), and the operator can reparent any
bootstrap container (`ruth`) is just another root. Re-parenting is agent, including the bootstrap container (`ruth`) — it's just another
operator-driven: root. The manager is reparentable like any other agent; there's no
"structurally root" carve-out. Its privileges live on its MCP socket,
not its tree position (see *Manager special-casing today* below).
- CLI: `hivectl agent <child> set-parent --parent <new>` (or `--root` to ### Reparenting
promote). Exactly one of `--parent` / `--root` is required.
- CLI: `hivectl agent <child> set-parent --parent <new>` (or `--root`
to promote). Exactly one of `--parent` / `--root` is required.
- Dashboard: `POST /api/topology/set-parent` (form fields `child`, - Dashboard: `POST /api/topology/set-parent` (form fields `child`,
optional `new_parent` — absent / empty ⇒ promote to root). optional `new_parent` — absent / empty ⇒ promote to root).
- Wire: `HostRequest::SetParent { child, new_parent: Option<String> }`. - Wire: `HostRequest::SetParent { child, new_parent: Option<String> }`.
All three converge on `topology::set_parent`, which delegates the All three go through the same validation, which refuses:
validation rules to a pure `apply_set_parent` helper. Refuses:
- unknown `child` / `new_parent` (typo guard), - unknown `child` / `new_parent` (typo guard),
- self-parenting, - self-parenting,
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`). - cycles (a bounded ancestor walk — moving the manager under one of
its own descendants is the only real safety concern here, and it's
caught the same way as any other agent).
The manager is reparentable like any other agent — there's no Setting a parent to its current value is a no-op (no disk write). A
"structurally root" carve-out; the manager's privileges live on its successful change triggers an immediate rescan, so connected dashboard
MCP socket, not its tree position, and the cycle walk above catches viewers see the tree repaint without polling.
the only real safety concern (moving the manager under one of its
own descendants).
Idempotent no-op fast path skips the disk write when the parent is
already what's requested. After a successful write the surfaces call
`Coordinator::rescan_containers_and_emit` so connected dashboard
viewers see the tree repaint without polling
(`ContainerView.parent` is sourced from `topology.json`).
**Today's caveat:** the move is purely a JSON edit. Only the
top-level manager (`root`) gets `/var/lib/hyperhive/agents`
bind-mounted at `/agents` in its container, so sub-agents don't yet
see their would-be children's state. Once sub-manager bind mounts
land alongside cap enforcement, `set_parent` grows a companion
umount-old / mount-new / restart-cascade step.
### Why meta, not per-agent `agent.nix` ### Why meta, not per-agent `agent.nix`
@ -65,37 +57,39 @@ consent, and operator-driven re-parenting shouldn't require touching
the moved agent's config. Topology IS a system-level concern; meta is the moved agent's config. Topology IS a system-level concern; meta is
where system-level facts live. where system-level facts live.
### Flow ### How `topology.json` gets updated
1. **Read**: `topology::read()` parses `topology.json` into a - **Read** — parsed into an agent→parent map; a missing or unparsable
`BTreeMap<String, Option<String>>`. Missing / unparsable file → file degrades safely to "every agent is root" (covers a fresh
empty map → every agent treated as root (safe degradation for install that hasn't synced yet).
fresh installs that haven't run `meta::sync_agents` yet). - **Reconcile** — runs alongside the periodic meta/flake regeneration.
2. **Reconcile**: `meta::sync_agents` calls `topology::reconcile` New agents default to root unless they already carry an explicit
alongside its `flake.nix` regeneration. New agents default to root parent edge from an `init_config` approval; existing entries
(null parent) — an agent-requested sub-agent already carries an (including operator overrides) are preserved; removed agents drop.
explicit requester-as-parent edge from its `init_config` approval, so Agents that are approved but not yet spawned keep their edge too, so
only user/operator-initiated spawns hit this default, and those are it survives the gap until the container actually appears.
roots; removed agents drop. Existing entries are preserved as-is so - **Inject** — each container's parent (if any) is exposed to its own
operator overrides stick across regenerations. Pending-init agents environment as `HIVE_PARENT`, so the harness / system-prompt
(an operator-approved proposed config repo but no container yet — renderer can see it.
`Coordinator::pending_init_names`) are kept too, so the - **Surface** — every rescan re-reads `topology.json` and populates
`child -> parent` edge written when `request_init_config` is approved `ContainerView.parent`, which the dashboard renders as a tree.
survives the gap until the first apply-commit spawns the container.
3. **Inject**: `meta::render_flake` looks up each agent's parent and
passes it to `mkAgent`. When non-null, the mkAgent body sets
`HIVE_PARENT = parent` in the agent's systemd service environment
so the harness / claude prompts can see it.
4. **Surface**: `container_view::build_all` reads `topology.json` and
populates `ContainerView.parent: Option<String>` on every rescan.
The dashboard renders the field as a tree.
## Target topology semantics See `hive-c0re/src/topology.rs` and `hive-c0re/src/meta.rs`'s module
docs for the exact call chain.
Once enforcement lands the rules collapse into: ### Current limitation: state-dir visibility lags topology
Reparenting today is purely a JSON edit. Only the top-level manager
(`root`) gets `/var/lib/hyperhive/agents` bind-mounted at `/agents` in
its container, so sub-agents don't yet see their would-be children's
state dirs. Once sub-manager bind mounts land alongside capability
enforcement, reparenting will grow a companion
umount-old / mount-new / restart-cascade step.
## Planned topology semantics (once ancestor-based enforcement lands)
| operation | who can do it | | operation | who can do it |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
| `request_init_config` (spawn a new child) | any agent, child added under self | | `request_init_config` (spawn a new child) | any agent, child added under self |
| config change via forge PR (any descendant's config) | any ancestor | | config change via forge PR (any descendant's config) | any ancestor |
@ -105,106 +99,71 @@ Once enforcement lands the rules collapse into:
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) | | `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
"Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a "Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a
visited-set at dispatch time (a malformed topology.json can't lock the visited-set at dispatch time (a malformed `topology.json` can't lock
dispatcher into a loop). the dispatcher into a loop).
## Current manager special-casings — the audit ## Manager special-casing today
What currently makes the manager different from every other agent, and Enforcement of the ancestor rules above isn't fully wired yet, so the
which axis the post-milestone version reads each special-case along: **manager (`ruth`) still gets some hard-coded special treatment**
other agents don't:
### A — naming + bootstrap - **Naming/bootstrap** — the manager's broker recipient name, state-dir
key, and nixos-container name are all `ruth` (container `h-ruth`).
`hive-c0re` spawns it directly at boot if missing, with no operator
approval step — every other agent goes through `request_init_config`
→ approval. Topology-wise, `ruth` is still just another root agent.
- **Wire-protocol** — the `ManagerRequest::*` operations
(`RequestInitConfig`; `Kill` / `Start` / `Restart` / `Update`;
`GetLogs`; `RequestUpdateMetaInputs`) are reachable only from the
manager's socket flavour today. Planned rule for each is in the
table above ("any agent, child added under self" for init-config,
"any ancestor" for lifecycle/logs); `RequestUpdateMetaInputs` stays
a root-only capability even post-milestone, not a topology rule.
One exception: `Wake` (inject a `from: <X>` message into the
caller's own inbox) isn't really privileged — every per-agent daemon
(e.g. `hive-forge-notify`) needs it, and sub-agents already have the
equivalent on their own socket.
- **Storage/mounts** — only the manager container gets
`/var/lib/hyperhive/agents` bind-mounted RW at `/agents` (so it can
manage any agent's state dir — config is not authored there, since a
real config change is a PR from a clone), plus RO mounts for
`/applied` (diff against what's deployed) and `/meta` (system-wide
deploy log). Planned: each agent gets RW to `/agents/<descendant>/`
for just its own subtree — the manager's full-forest RW becomes the
"root's subtree is everything" case of that same rule. RO `/meta`
access will be gated on a "meta read" capability; only
`request_update_meta_inputs` writes `flake.lock`, gated by its own
capability.
- **Prompt/tools** — the system prompt uses `<!-- role:agent -->` /
`<!-- role:manager -->` marker blocks, and a `Flavor::{Agent,
Manager}` switch picks the MCP tool allow-list claude sees. Both are
already parametrised on a single flavour value, so the planned
per-capability-group version (`cap:<group>` prompt blocks + a
matching tool allow-list) is additive rather than a rewrite.
- **State dirs***not* special-cased: `HYPERHIVE_STATE_DIR` is
injected uniformly via `systemd.globalEnvironment` for every
container including the manager, so all token/state paths resolve
through it the same way everywhere.
- **Scattered ownership checks** — a handful of independent
manager-only overrides exist across `hive-c0re` today: loose-ends
visibility (manager sees hive-wide, sub-agents only their own),
"manager can cancel any question/reminder" overrides on the owner
check, `destroy` refusing to act on the manager, and crash-watch
skipping the manager (it auto-restarts via systemd instead of going
through the crash-watch loop). Each is planned to become an
ancestor/descendant check instead of a manager-name check — see the
module docs for `loose_ends.rs`, `operator_questions.rs`,
`broker.rs`, `reminder_scheduler.rs`, `actions.rs`, and
`crash_watch.rs` for the current owner-check logic in each.
- `MANAGER_AGENT = "ruth"` (broker recipient name), None of the above is a stable interface — treat the module doc
`MANAGER_NAME = "ruth"` (logical name, state-dir key), and comments as the source of truth for exactly which checks exist today.
`MANAGER_CONTAINER = "h-ruth"` (nixos-container name); the `h-`
prefix lets `lifecycle::list()` use a single `starts_with("h-")`
filter.
- `auto_update::ensure_manager` runs at hive-c0re boot and spawns
`h-ruth` if missing. **Topology**: ruth defaults to root-level (no
parent); hive-c0re handles the bootstrap lifecycle directly.
### B — wire-protocol privileges ## Future work: sub-agents inside the same container
The `ManagerRequest::*` variants in `hive-sh4re/src/lib.rs` are When enabled for an agent, it will be able to spawn temporary
operations the manager flavour socket can make that sub-agent sockets "sub-agents" that run inside its own container — lighter than a full
can't:
| variant | semantic | post-milestone |
| --------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RequestInitConfig` | seed an agent's proposed config repo | **topology** — existing direct child (re-init) or a brand-new name (child added under self on approval); a name owned by a different parent is refused |
| `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only |
| `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) |
| `GetLogs` | journalctl scrape of a sub-agent | **topology** — descendants only |
| `Wake` | inject a `from: <X>` message into self's inbox | **not really privileged** — the wire surface exists because the per-agent daemons (e.g. `hive-forge-notify`) need it. Sub-agents have the same via their own socket. |
### C — storage / mounts (`hive-c0re::lifecycle`)
The manager container's nspawn bind set:
- `HOST_AGENTS_ROOT (/var/lib/hyperhive/agents) → /agents` RW — so the
manager can manage any agent's state dir. Config is **not** authored
here: a config change is a PR from a clone, and `<agent>/config/` is
a copy for reading (its write access is a defect tracked separately)
- `HOST_APPLIED_ROOT (/var/lib/hyperhive/applied) → /applied` RO — so
the manager can diff against what's deployed
- `HOST_META_ROOT (/var/lib/hyperhive/meta) → /meta` RO — so the
manager can read the system-wide deploy log
Tree-shape version:
- Each agent gets RW to `/agents/<descendant>/` for every descendant in
its subtree. The root agent (today: manager) gets RW to the full
forest as a special case of "the root has every other agent as a
descendant".
- RO `/meta` access if the agent holds a "meta read" cap.
- `request_update_meta_inputs` is the only path that actually writes
`flake.lock`, gated by the cap; everyone else stays RO.
### D — drop legacy `/state` for manager ✓ done
`lifecycle.rs` no longer binds `/state` for the manager.
`HYPERHIVE_STATE_DIR` is now injected uniformly via
`systemd.globalEnvironment` in `meta.rs` for every container
(manager included), so all token/state paths resolve through
`$HYPERHIVE_STATE_DIR`. The agent-module shell scripts
(tea-login, forge-avatar-sync) simplified from glob+for loops to a
direct `$HYPERHIVE_STATE_DIR/<token>` read.
### E — prompt + tools
- `prompts/system.md` with `<!-- role:agent -->` / `<!-- role:manager -->`
marker blocks, assembled by `hive_ag3nt::prompt::render` based on
flavor. **Per-agent cap list** of what the agent can do — already
a single parametrised prompt; once per-agent cap groups land the
marker grammar grows `cap:<group>` blocks the renderer reads from
the per-agent ToolGroup set.
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
sees. Already structured this way internally — the per-flavour
allow-list becomes a per-cap-set lookup.
### F — drive-by checks across c0re
- `loose_ends.rs`: manager sees hive-wide loose-ends, sub-agents only
their own. **Topology** — every agent sees its own + its
descendants'.
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
question" override on the owner check. **Topology** — agents can
moderate threads of their descendants.
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
**Topology** — descendants only.
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
foot-shooting). **Topology** — agents can destroy descendants but
never themselves or ancestors.
- `crash_watch.rs`: skips `ContainerCrash` for the manager (it
auto-restarts via systemd). **Topology** — the root container has
different recovery semantics, every other agent falls into the same
watch loop.
### G — sub-agents inside the same container
Future work: when enabled for an agent, it can spawn temporary
"sub-agents" that run inside its own container. Lighter than a full
nspawn agent. Open questions, not yet wired: nspawn agent. Open questions, not yet wired:
- Inherit caps from parent, or take an explicit narrower set? - Inherit caps from parent, or take an explicit narrower set?
@ -216,17 +175,16 @@ nspawn agent. Open questions, not yet wired:
## Harness systemd unit shape ## Harness systemd unit shape
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 sibling), one shared `nix/agent-modules/` tree, one service unit
service unit (`systemd.services.hive-agent`) for all agents. There (`systemd.services.hive-agent`) for all agents. There is no separate
is no longer a separate manager service name or role distinction in manager service name or role distinction in the harness — privilege
the harness — privilege differences live server-side in the broker differences live server-side in the broker socket (which tool groups
socket (which tool groups and manager-surface calls each agent and manager-surface calls each agent receives).
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/`.
`ruth.nix` additionally sets forge defaults to suppress the `ruth.nix` additionally sets forge defaults to suppress the
subscription/participation firehose so ruth's inbox stays focused subscription/participation firehose so ruth's inbox stays focused on
on direct mentions, reviews, and assignments. direct mentions, reviews, and assignments.
### Environment variables set on the unit ### Environment variables set on the unit
@ -248,32 +206,30 @@ on direct mentions, reviews, and assignments.
path = [ "/run/wrappers" "/run/current-system/sw" ]; path = [ "/run/wrappers" "/run/current-system/sw" ];
``` ```
`/run/wrappers` comes first so setuid wrappers (notably `sudo`) `/run/wrappers` (not `/run/wrappers/bin`) comes first so setuid
resolve before bare nix-store binaries. NixOS's wrappers — notably `sudo` — resolve before bare nix-store binaries; see
`systemd.services.<unit>.path` appends `/bin` to every entry via [`docs/gotchas.md`](gotchas.md) ("`systemd.services.*.path` appends
`lib.makeBinPath`; passing `/run/wrappers/bin` directly produces `/bin` to every entry") for why the trailing `/bin` matters in
`/run/wrappers/bin/bin` which doesn't exist (`docs/gotchas.md:: general. It's load-bearing here because the harness runs as the
systemd.services.*.path appends /bin to every entry`). With the per-agent user: without the wrapper dir on `PATH`, `sudo` resolves to
harness running as the per-agent user this matters: without the the non-setuid nix-store binary and every
wrapper dir on PATH, `sudo` resolves to the un-setuid nix-store `hyperhive.user.passwordlessSudo` grant fails with "must be owned by
binary and rejects with `must be owned by uid 0 and have the setuid uid 0 and have the setuid bit set."
bit set` regardless of `hyperhive.user.passwordlessSudo`.
### `serviceConfig` highlights ### `serviceConfig` highlights
- `ExecStart = pkgs.hyperhive/bin/hive-agent` — same binary for - `ExecStart = pkgs.hyperhive/bin/hive-agent` — same binary for every
every agent. agent.
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness - `Restart = on-failure`, `RestartSec = 2` — keeps the harness
resilient across transient crashes without thundering retries. resilient across transient crashes without thundering retries.
- `RuntimeDirectory = "hive-config"``/run/hive-config/` owned by - `RuntimeDirectory = "hive-config"``/run/hive-config/` owned by
`User=`, auto-cleared on stop. The harness writes regenerated `User=`, auto-cleared on stop. The harness writes regenerated
`claude-{mcp-config,settings,system-prompt}` files there `claude-{mcp-config,settings,system-prompt}` files there
(`paths::config_dir`). Deliberately separate from `/run/hive`, (`paths::config_dir`). Deliberately separate from `/run/hive`, which
which the host bind-mounts in root-owned and which holds the host bind-mounts in root-owned and which holds hive-c0re's
hive-c0re's `mcp.sock`. `mcp.sock`.
- `User = Group = userName` — drops root inside the container; sudo - `User = Group = userName` — drops root inside the container; sudo is
is the explicit escalation surface the explicit escalation surface (`hyperhive.user.passwordlessSudo`).
(`hyperhive.user.passwordlessSudo`).
## Cross-references ## Cross-references