Ninth batch of the ongoing write-good.Passive pass (hyperhive#4042):
read all 75 hits across agent-hierarchy.md/persistence.md/approvals.md
in context and rewrote 46 with a clearly nameable actor -- mostly
hive-c0re, a specific fn/type named right there or a sentence or two
earlier (approvals.md's numbered "Reminder delivery" list all share
hive-c0re as the established actor across three consecutive bullets),
or "the worker"/"the daemon" reused from the section's own established
subject.
Left 29 alone. Recurring buckets: predicate-adjective state pairs
("is wedged"/"is stopped", "is unaffected", "are unrelated", "is
overloaded" -- matches the established "is trusted"/"is privileged"
family), negative-capability/invariant idioms ("no X is needed",
"can't be removed/started", "no such transient was cleared"),
forward-looking design-intent statements about not-yet-built
enforcement (agent-hierarchy.md's whole "Planned topology semantics"
section leans on this: "are meant to run", "once enforcement is
finished", "will be gated"), the "audit trail, not cache" policy
invariant restated twice verbatim ("Approvals are kept forever" /
"indefinitely" -- left both alone consistently), and a config-
conditional state idiom ("when interval_seconds is set"). One more
false-positive tokenization, same bug as knowledge.md's earlier case:
persistence.md's "`config` is read-only" trips vale's "is read" match
inside the compound adjective -- not a real passive at all.
One deliberately-conservative leave: agent-hierarchy.md's "Reminder
cancellation is handled fully in-agent" had an ambiguous actor (the
in-agent socket vs. the hive-c0re source file the doc points readers
to for detail) -- left alone rather than risk a wrong attribution,
unlike the higher-confidence rewrites elsewhere in the same batch.
Verified via vale before/after: 75 -> 29 write-good.Passive hits,
exactly the 29 left alone above; error count and other warning
categories unchanged. Re-read every changed line in full surrounding
context after editing before running the final vale check.
12 KiB
Agent hierarchy & privileges
Every agent has a place in an operator-editable parent/child tree, used
to scope which agents can manage which others. This doc covers how
hive-c0re stores and edits the tree today, the rules that are meant to run on top
of it once enforcement is finished, and where the manager still gets
special-cased in the meantime. Tracking issue: hyperhive#361
($HIVE_FORGE_URL/hyperhive/hyperhive/issues/361).
Where the tree lives
Topology lives in the hive-c0re-owned meta repo, alongside
flake.nix, at /var/lib/hyperhive/meta/topology.json:
{
"ruth": null,
"alice": null,
"bob": "alice"
}
null = root-level agent. New agents default to root — there is no
structural manager that everything hangs under. Hierarchy is built
explicitly: an agent that requests a sub-agent gets a
requester-as-parent edge written at its init_config approval (so
alice spawned bob above), and the operator can reparent any
agent, including the bootstrap container (ruth) — it's just another
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).
Reparenting
- CLI:
hivectl agent <child> set-parent --parent <new>(or--rootto promote). Exactly one of--parent/--rootis required. - Dashboard:
POST /api/topology/set-parent(form fieldschild, optionalnew_parent— absent / empty ⇒ promote to root). - Wire:
HostRequest::SetParent { child, new_parent: Option<String> }.
All three go through the same validation, which refuses:
- unknown
child/new_parent(typo guard), - self-parenting,
- 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).
Setting a parent to its current value is a no-op (no disk write). A successful change triggers an immediate rescan, so connected dashboard viewers see the tree repaint without polling.
Why meta, not per-agent agent.nix
An agent shouldn't be able to claim a parent without that parent's consent, and operator-driven re-parenting shouldn't require touching the moved agent's config. Topology IS a system-level concern; meta is where system-level facts live.
How topology.json gets updated
- Read — parsed into an agent→parent map; a missing or unparsable file degrades safely to "every agent is root" (covers a fresh install that hasn't synced yet).
- Reconcile — runs alongside the periodic meta/flake regeneration.
New agents default to root unless they already carry an explicit
parent edge from an
init_configapproval; Reconcile preserves existing entries (including operator overrides); removed agents drop. Agents that are approved but not yet spawned keep their edge too, so it survives the gap until the container actually appears. - Inject — hive-c0re exposes each container's parent (if any) to its own
environment as
HIVE_PARENT, so the harness / system-prompt renderer can see it. - Surface — every rescan re-reads
topology.jsonand populatesContainerView.parent, which the dashboard renders as a tree.
See hive-c0re/src/agent_config/topology.rs and hive-c0re/src/meta.rs's
module docs for the exact call chain.
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 |
|---|---|
kill / start / restart / update (any descendant) |
any ancestor |
request_init_config (spawn a new child) |
any agent, child added under self |
| config change via forge PR (any descendant's config) | any ancestor |
get_logs (any descendant) |
any ancestor |
| moderate reminders (cancel any open thread of a descendant) | any ancestor |
send / recv routing |
parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
request_update_meta_inputs (bump meta lock) |
root agents only (today: just manager) |
"Ancestor" walks ContainerView.parent chains; a visited-set guards against
cycles at dispatch time (a malformed topology.json can't lock
the dispatcher into a loop).
Manager special-casing today
Enforcement of the ancestor rules above isn't fully wired yet, so the
manager (ruth) still gets some hard-coded special treatment
other agents don't:
- Naming/bootstrap — the manager's broker recipient name, state-dir
key, and nixos-container name are all
ruth(containerh-ruth).hive-c0respawns it directly at boot if missing, with no operator approval step — every other agent goes throughrequest_init_config→ approval. Topology-wise,ruthis still just another root agent. - Wire-protocol — the privileged
Requestvariants (RequestInitConfig;Kill/Start/Restart/Update;GetLogs;RequestUpdateMetaInputs) — marked*(privileged)*inhive-core-agent-sock's unifiedRequestenum — 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);RequestUpdateMetaInputsstays a root-only capability even post-milestone, not a topology rule. One exception:Wake(inject afrom: <X>message into the caller's own inbox) isn't really privileged — every per-agent daemon (for examplehive-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/agentsbind-mounted RW at/agents(so it can manage any agent's state dir — config isn't authored there, since a real config change is a PR from a clone), plus RO mounts for/applied(diff against what's deployed) and/meta(system-wide deploy log). 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/metaaccess will be gated on a "meta read" capability; onlyrequest_update_meta_inputswritesflake.lock, gated by its own capability. - Prompt/tools — the system prompt uses
<!-- role:agent -->/<!-- role:manager -->marker blocks, and aFlavor::{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_DIRis injected uniformly viasystemd.globalEnvironmentfor 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-c0retoday: loose-ends visibility (manager sees hive-wide, sub-agents only their own),destroyrefusing to act on the manager, and crash-watch skipping the manager (it autorestarts 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 forloose_ends.rs,stores/broker.rs,actions.rs, andworkers/crash_watch.rsfor the current owner-check logic in each. (Reminder cancellation is handled fully in-agent — see the note onCancelLooseEndKind::Reminderinhive-c0re/src/socket_server/mod.rs.)
None of the above is a stable interface — treat the module doc comments as the source of truth for exactly which checks exist today.
Future work: sub-agents inside the same container
When enabled for an agent, it will be able to spawn temporary "sub-agents" that run inside its own container — lighter than a full nspawn agent. Open questions, not yet wired:
- Inherit caps from parent, or take an explicit narrower set?
- Survive container restart, or always ephemeral?
- Inbox: separate from parent, or shared?
- Filesystem: share parent's
/stateRW, or a sub-dir? - Identity: distinct broker recipient name, or address the parent?
Harness systemd unit shape
One harness serve binary (hive-agent, with its hive-agent-mcp
sibling), one shared nix/agent-modules/ tree, one service unit
(systemd.services.hive-agent) for all agents. No separate manager
service name or role distinction exists in the harness — privilege
differences live server-side in the broker socket (which tool groups
and manager-surface calls each agent receives).
agent.nix and ruth.nix both import the shared nix/agent-modules/.
ruth.nix additionally sets forge defaults to suppress the
subscription/participation firehose so ruth's inbox stays focused on
direct mentions, reviews, and assignments.
Environment variables set on the unit
HOME = /home/<userName>— systemd defaultsHOMEto/for services withoutUser=set; with the per-agent user the harness needs the right home so claude finds its bind-mounted~/.claude/session dir.HIVE_STATIC_DIR = <mergedDist>—tower_http::ServeDirroot for the per-agent web UI; merged dist = agent default + everyhyperhive.frontend.extraFilesoverlay.HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive— set directly on the unit, not viaenvironment.variables, because the latter only populates/etc/profilewhich systemd services don't inherit.
PATH setup (the wrapper-dir trick)
path = [ "/run/wrappers" "/run/current-system/sw" ];
/run/wrappers (not /run/wrappers/bin) comes first so setuid
wrappers — notably sudo — resolve before bare nix-store binaries; see
docs/process/gotchas.md ("systemd.services.*.path appends
/bin to every entry") for why the trailing /bin matters in
general. It's load-bearing here because the harness runs as the
per-agent user: without the wrapper dir on PATH, sudo resolves to
the non-setuid nix-store binary and every
hyperhive.user.passwordlessSudo grant fails with "must be owned by
uid 0 and have the setuid bit set."
serviceConfig highlights
ExecStart = pkgs.hyperhive/bin/hive-agent— same binary for every agent.Restart = on-failure,RestartSec = 2— keeps the harness resilient across transient crashes without thundering retries.RuntimeDirectory = "hive-config"→/run/hive-config/owned byUser=, autocleared on stop. The harness writes regeneratedclaude-{mcp-config,settings,system-prompt}files there (paths::config_dir). Deliberately separate from/run/hive, which the host bind-mounts in root-owned and which holds hive-c0re'smcp.sock.User = Group = userName— drops root inside the container; sudo is the explicit escalation surface (hyperhive.user.passwordlessSudo).
Cross-references
- Milestone: "Agent privileges and sub-agents"
(
$HIVE_FORGE_URL/hyperhive/hyperhive/issues/361) - Dashboard render: "show agent topology in container list"
(
$HIVE_FORGE_URL/hyperhive/hyperhive/issues/363) - Audit table source: milestone comment
(
$HIVE_FORGE_URL/hyperhive/hyperhive/issues/361#issuecomment-3335) - Operator/agent trust boundary (orthogonal axis):
boundary.md