hyperhive/docs/boundary.md
atlas 480b35aac4 docs(boundary): update to reflect always-on network isolation
All three area:ops pillars are now complete: network isolation is
always on (private netns, no shared-netns mode), the gateway is live,
and hive-c0re runs as the unprivileged hive-core user.

- Rewrite the intro to say the boundary is enforced, not aspirational
- Update 'Why network isolation is the load-bearing step' section to
  use past tense for the shared-netns world and note completion
- Mark network isolation as Complete in the sequencing list
2026-07-04 20:58:55 +02:00

3.8 KiB

The operator/agent boundary

Design rationale for hyperhive's two-principal trust model. The implementation work — container network isolation, the unifying gateway, core-daemon privsep — is tracked as area:ops issues on the forge.

The operator/agent boundary is now technically enforced, not just a convention. Containers run in private netns (network isolation is always on), the gateway proxies all operator-facing traffic, and hive-c0re runs as the unprivileged hive-core user. All three area:ops pillars — network isolation, the gateway, and privsep — are complete and active.

Two principals, two paths

  • Operator — reaches every UI (the dashboard + every per-agent page) through the gateway, on one origin. Operator-authority actions (approve / deny, answer-as-operator, lifecycle POSTs) are served by the core daemon and only reachable via the gateway.
  • Agent — speaks only for itself, only over its per-agent unix socket. The socket's identity is the agent (see docs/conventions.md, "identity = socket"). An agent must not be able to reach the core daemon's HTTP surface, another agent's socket, or another agent's web UI.

Design rule

Operator-authority actions never get a per-agent-socket entry point. They live on the core backend.

Worked example — answering an operator-targeted question is a POST /answer-question/{id} on the core dashboard, never an AgentRequest variant. If it were a per-agent-socket request, an agent could curl its own socket and spoof an operator answer. The per-agent web UI POSTs cross-origin to the core for these (see the inline-answer feature — the loose-ends section on each agent page).

Why network isolation is the load-bearing step

Without network isolation, containers share the host network namespace and can reach localhost:<core-port>, the dashboard, and every other agent's web port — the operator/agent split is on the honour system and every boundary claim above is aspirational. Network isolation is what makes the boundary real; the gateway and privsep are ergonomics and defence-in-depth layered on top.

Network isolation is now complete and always on: every agent container runs in a private netns behind the hive bridge. The shared-netns mode was removed. See docs/network.md.

The area:ops issues followed this sequencing:

  1. Gateway — pure ergonomics win, unblocks same-origin (lets the cross-origin CORS shim on /answer-question/{id} go away), no behavioural risk. An nginx nixos-container now sits in front of all surfaces; per-agent UIs are proxied under /agent/<name>/.
  2. Network isolation — the load-bearing step that turns the honour-system split into an enforced boundary. Complete — always-on, unconditional; the shared-netns mode was removed.
  3. Privsep — defence in depth on the core process; hive-c0re runs as the unprivileged hive-core user and delegates root operations to hive-priv, a narrow socket-activated helper. See docs/security.md for the privilege boundary table.

hive-priv socket activation

hive-priv is always socket-activated by the hive-priv.socket systemd unit. The unit binds /run/hive/priv.sock with SocketGroup=hive-core and mode 0660 and passes the ready listener to the helper as fd 3 (LISTEN_FDS). The helper requires this and bails if it isn't socket-activated — there is intentionally no self-bind fallback.

Dropping the old fallback removed a dev/prod divergence: when hive-priv bound the socket itself it created the file owned by root's primary group rather than hive-core, so a hive-core client couldn't connect the way the socket unit's SocketGroup grant intends. Requiring socket activation everywhere means dev and prod take the exact same path and the group grant always holds.