From fb1f7efbe430974a6fd8dda04a1c3f6df826a748 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 8 Jun 2026 20:04:05 +0200 Subject: [PATCH] docs: move privsep socket-activation + child-state rw rationale out of code comments --- docs/boundary.md | 16 ++++++++++++++++ docs/persistence.md | 14 ++++++++++++++ hive-c0re/src/lifecycle.rs | 22 +++++++--------------- hive-priv/src/main.rs | 11 +++-------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/docs/boundary.md b/docs/boundary.md index fe93de85..dc47a117 100644 --- a/docs/boundary.md +++ b/docs/boundary.md @@ -59,3 +59,19 @@ The `area:ops` issues followed this sequencing: runs as the unprivileged `hive-core` user and delegates root operations to `hive-priv`, a narrow socket-activated helper. See [`docs/security.md`](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. diff --git a/docs/persistence.md b/docs/persistence.md index 0a7dff4b..d1f297b2 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -220,6 +220,20 @@ Under `/var/lib/hyperhive/agents//`: - `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-model` — single-line model name override file. +### Parent access to child state + +A parent agent gets each direct child's `state`, `harness`, and +`config` dirs bind-mounted **read-write** (`bind_child_agent_dirs` in +`lifecycle.rs`). The RW on `state` is deliberate, not an oversight: a +parent manages its children, which includes writing into a child's +state for recovery (e.g. seeding notes, clearing a stuck sentinel) as +well as reading it. `config` is RW because the parent authors proposed +config changes for the child (the approval flow commits into the +child's config repo), and `harness` is RW for the same management +reasons. Per-child isolation still holds: a container only ever has +its *own* dirs plus its direct children's bind-mounted, never a +sibling's. + Under `/var/lib/hyperhive/applied//` — the hive-c0re-only applied repo. Tracks `flake.nix` (module-only boilerplate; never edited after first spawn) + `agent.nix` (the actual config; the diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index d71020cb..0816ec4d 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1100,18 +1100,10 @@ const HOST_META_ROOT: &str = "/var/lib/hyperhive/meta"; const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; /// Append bind flags for `child`'s state, harness, and config dirs into -/// `binds`. All three are RW so the parent can read/write state and -/// submit config-change requests. Creates missing host-side directories -/// so nspawn doesn't refuse to start; missing dirs are non-fatal. -/// Mount a child agent's `state`, `harness`, and `config` dirs into the -/// parent, all read-write. The RW on `state` is deliberate (not just a -/// read mount): a parent manages its children, which includes writing -/// into a child's state for recovery (e.g. seeding notes / clearing a -/// stuck sentinel) as well as reading it. `config` is RW because the -/// parent authors proposed config changes for the child (the approval -/// flow commits into the child's config repo). `harness` is RW for the -/// same management reasons. Per-child isolation still holds: a child -/// only ever has its *own* dirs bind-mounted, never a sibling's. +/// `binds`, all read-write. The RW on `state` is deliberate (recovery), +/// not an oversight; see docs/persistence.md ("Parent access to child +/// state") for the rationale. Creates missing host-side directories so +/// nspawn doesn't refuse to start; missing dirs are non-fatal. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state"); let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness"); @@ -1229,9 +1221,9 @@ async fn set_nspawn_flags( }); // Topology-driven child mounts: every direct child of this agent gets - // its state, harness, and config dirs bind-mounted RW so the parent - // can read AND write child state (recovery) and manage its config. - // See `bind_child_agent_dirs` for why state is RW, not read-only. + // its state, harness, and config dirs bind-mounted RW (parent reads + + // writes child state for recovery, and manages config). See + // `bind_child_agent_dirs`. let direct_children = crate::topology::children_of(agent_name); for child in &direct_children { bind_child_agent_dirs(child, &mut binds); diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 09c6ef78..a830c7da 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -57,14 +57,9 @@ async fn main() -> Result<()> { } fn socket_listener() -> Result { - // hive-priv is ALWAYS socket-activated: systemd's `hive-priv.socket` - // unit binds `/run/hive/priv.sock` (SocketGroup=hive-core, mode 0660) - // and passes it as fd 3 via LISTEN_FDS. We require that — there is - // intentionally no self-bind fallback, so dev and prod take the exact - // same path. (The old fallback re-bound the socket itself as root's - // primary group, never `hive-core`, so a hive-core client couldn't - // connect the way the socket unit's grant intends; dropping it removes - // that dev/prod divergence.) + // hive-priv is ALWAYS socket-activated by the `hive-priv.socket` unit + // (fd 3 via LISTEN_FDS). There is intentionally no self-bind fallback, + // so dev and prod take the same path; see docs/boundary.md. let listen_fds: Option = std::env::var("LISTEN_FDS") .ok() .and_then(|s| s.parse().ok());