Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/docs/4-implementation/trust-boundary/hive-c0re-privilege.md

31 KiB

hive-c0re privilege separation

How hive-c0re runs unprivileged and delegates its root operations to hive-priv, from the security model.

Background

hive-c0re runs as the unprivileged system user hive-core (/var/lib/hyperhive owned by hive-core:hive-core). It can't directly invoke nixos-container or act on a system unit (systemctl reload nginx) — those require root. hive-priv fills this gap.

⚠️ ReloadGatewayNginx acts on a host unit, so nothing implicitly scopes it. Its containment is the unit name hard-coded in hive-priv: a caller can't name the unit, so it can't steer the verb at another service. A privileged verb needs something bounding what it can act on; when that isn't a namespace, it has to be a constant the caller can't supply.

hive-priv

hive-priv is a minimal privileged helper that runs as root, socket-activated at /run/hive/priv.sock (mode 0660, group hive-core — only the hive-core user can connect). hive-c0re calls it via priv_client for every operation that genuinely requires root.

Narrow interface — PrivRequest variants map 1:1 to specific known operations; there is no arbitrary command pass-through:

Operation What it runs
StartContainer / StopContainer nixos-container start/stop <name>
KillContainer machinectl kill <machine> --signal=SIGKILL (nixos-container has no kill verb)
StartFailureLog journalctl _SYSTEMD_UNIT=container@h-<name>.service --since @<t> -n 30 -o cat --no-pager, fixed argv, 5 s timeout, output capped at 8 KiB; hive-priv clamps <t> to the last hour. hive-c0re appends the lines to the failed start's build log, which only the dashboard serves.
CreateContainer / UpdateContainer nixos-container create <name> --system-path <toplevel>; for an update, nix-env -p <profile> --set <toplevel> and a reload of the unit. <toplevel> must already be in the store with a signature by the swarm cache's key: hive-priv runs nix store verify --no-contents --sigs-needed 1 with trusted-public-keys set to the key in root-owned /run/hyperhive-priv/attic/public-key, and refuses a path that's absent or that the key didn't sign. hive-priv builds nothing. The check doesn't bind the path to the agent whose pull produced it: hive-c0re could already pull any swarm-signed path and apply it to any agent, so the check bounds the same risk (one agent gets another's closure), not an arbitrary-path one
RealiseClosure Kept for one release, for an older hive-c0re that still asks hive-priv to download. Downloads nothing: answers Ok when the path is in the store and signed by the swarm key (what CreateContainer needs), else not available: <reason>. The path must be /nix/store/<32 chars>-<name> and the agent name valid
RefreshCacheFacts systemctl start hive-priv-cache-facts.service, waited for up to 60 s. The request names no unit and has no field. hive-c0re asks for it when its netrc or the swarm key in the fragment is missing
DestroyContainer nixos-container destroy <name>
ListContainers nixos-container list
ReloadGatewayNginx systemctl reload/start/reset-failed nginx (host unit; the unit name is hard-coded, not a parameter)
WriteNspawnFlags write /etc/nixos-containers/<container>.conf (bind-mount list, network isolation vars, and --ephemeral after the root's salvage). hive-priv first copies a stopped container's root to the agent's work/, then renames it aside (it deletes nothing), following no link the container could have planted; for an agent whose closure record sets private_users it also adds --private-users=pick, mounts every bind owneridmap, and once chowns the agent's state, harness, work and claude dirs (never config, following no link) to the data owner
EnsureAgentSocketDir mkdirat /run/hive-agent/<name> 0751 root:root under an O_NOFOLLOW parent fd; leaves an existing directory alone, refuses anything else; for an agent whose closure record sets private_users it then chowns the dir to the data owner
WriteResourceLimits write CPUQuota=/MemoryMax=/CPUWeight=/IOWeight= systemd drop-in for agent container, with an ExecStartPre= that runs hive-priv check-rootfs <container> (no-follow check of the root paths the start script touches; see persistence.md), and a GC root on the build it names
RemoveServiceDropin remove container@<name>.service.d/ drop-in on destroy, and the GC root of the hive-priv build it named
DaemonReload systemctl daemon-reload
ControlInfraContainer systemctl <action> nginx.service for the gateway — the InfraContainer enum is the allowlist, and serde rejects unknown names at the wire boundary (hive-c0re, the forge and the matrix have no variant, so no request can name them)
SyncAgentTmpfiles unlink /etc/tmpfiles.d/hyperhive-agents.conf and return Ok
SetAgentPaused create / remove the <state>/<name>/harness/paused marker that parks an agent's turn loop
EnsureAgentSubvolume btrfs subvolume create <state>/<name> for a new agent — no-op when the path exists or the filesystem isn't btrfs
UpgradeAgentSubvolume migrate an existing plain state dir into a subvolume: create, cp -a --reflink=auto, atomic swap. Operator opt-in, and the caller stops the agent first
DeleteAgentSubvolume btrfs subvolume delete <state>/<name> — purge path only, never a plain destroy
EnsureBtrfsQuota btrfs quota enable <AGENT_STATE_ROOT>. Operator opt-in — enabling forces a full rescan
ReadSubvolumeUsage btrfs qgroup show -f --raw <state>/<name>
SetSubvolumeQuota btrfs qgroup limit <bytes|none> <state>/<name>
MeasureAgentDisk du -s -x -b -c -P over <state>/<name>/state, <state>/<name>/work and /var/lib/nixos-containers/h-<name>, killed after 10 min. hive-priv derives the paths from the name; the request carries none
SnapshotAgentSubvolume btrfs subvolume snapshot -r <agent_root> <snapshot_path> — the frozen point-in-time copy a migration streams from
DeleteAgentSnapshot btrfs subvolume delete <snapshot_path>
SendAgentSnapshotToFile btrfs send [-p <parent>] <snapshot> into a bare filename under MIGRATE_STAGING_ROOT; refuses to overwrite an existing export
SendAgentSnapshotToFd btrfs send [-p <parent>] <snapshot> into a file descriptor passed with the request (SCM_RIGHTS). hive-c0re connects to the peer and hands over the connected socket, so hive-priv never learns an address or protocol. hive-priv requires exactly one descriptor here, and refuses one that arrives alongside any other operation

Strings that reach root-loaded config — WriteResourceLimits and WriteNspawnFlags copy request strings into a systemd drop-in and an env file that systemd and the nixos-containers start script read, so hive-priv refuses, never escapes, any value outside its shape. memory_max is infinity, a decimal percentage or a decimal size with an optional B/K/M/G/T/P/E; cpu_quota is a decimal percentage; bridge is 1-15 ASCII letters, digits, -, _ or .; gateway_ip is an IPv4 literal. A bind or credential path is absolute with no whitespace, control byte, \, $, backtick, " or :. The size limits of a value stay with systemd.

Closures from the swarm cache — hive-c0re pulls the closure the swarm built with nix copy --from <cache address>, and hive-priv applies it only when the swarm's key signed it. Pulling needs no root. The nix client does the HTTP fetch itself, as hive-core, with a netrc only hive-core can read, and hands the NARs to the host's nix daemon, which stores a path from an unprivileged client only if a key in its trusted-public-keys signed it. Root-owned sources alone decide what the daemon accepts:

  • The cache signs. The swarm's cache (attic) holds the signing key and signs each narinfo it serves; the private key never leaves it. The builder pushes with a push token and holds no signing key.
  • The address is the host's configuration: services.hyperhive.swarm.attic.domain and cache, handed to hive-c0re in HIVE_C0RE_CACHE_URL. The daemon has no address: it never contacts the cache.
  • The key and the pull token come from the secret store. The root unit hive-priv-cache-facts reads the hive's own copies under the hive's own store identity and writes the key to /run/hyperhive-priv/attic/public-key and the token to netrc there (directory 0700, files 0600, root). It also writes the key into a nix.conf fragment, /run/hyperhive-nix/cache.conf (0644), as extra-trusted-public-keys, which the host's nix.conf includes (!include, so a host whose unit hasn't run yet still starts). The fragment holds no secret, names no substituter and no netrc. hive-priv reads nothing from /run/hyperhive/attic/, which hive-c0re writes for the probe.
  • hive-core holds the pull token. The unit writes a second copy of the netrc, /run/hyperhive-nix/netrc, root:hive-core 0640, and hive-c0re passes it to nix copy as the client's --option netrc-file. This is a deliberate grant, and no new capability: hive-c0re could already have hive-priv download any swarm-signed path for any agent and apply it (the CreateContainer row above), and the token reads only the cache the swarm signs. The daemon ignores a client's netrc-file.
  • The daemon enforces. It refuses a path from an unprivileged client unless a trusted key signed it; a client can't lower require-sigs or add keys. nix copy imports and never builds.
  • hive-priv verifies. Before CreateContainer or UpdateContainer applies a toplevel, it runs nix store verify --no-contents --sigs-needed 1 with trusted-public-keys set to the key in the root file, and refuses a path that is absent or that the key didn't sign. The daemon's trusted keys also include the host's other caches, so this check is the one that counts only the swarm's.
  • When the netrc or the key is missing, hive-c0re asks hive-priv for RefreshCacheFacts, which starts the facts unit and waits up to 60 s, instead of waiting for the unit's timer. A host the swarm hasn't given facts to answers this hive has no cache facts yet.

What an agent with daemon access can fetch. Agent containers on nixBuilds = host reach the same daemon (nix-builds.md). They get nothing from the private cache: the daemon has no trusted substituter and no netrc for it, an agent can't read /run/hyperhive-nix/netrc (hive-core's group) or /run/hyperhive-priv/attic/, and an unprivileged client can't make the daemon use a substituter or a netrc it didn't configure. The one new thing every client of the daemon shares is the swarm's public key in trusted-public-keys: the daemon takes a path the swarm key signed from any source, and only the swarm can produce one.

What a compromised hive-c0re can do: pull any store path that the swarm cache signed, which fills the store with paths the swarm vouches for, and then ask hive-priv to apply any such path to any agent. Before this change it could already do both through RealiseClosure and an apply; the former check bound an apply to the path hive-priv itself downloaded for that agent, and not to the agent the swarm built it for. It can't make root accept a path from a cache or under a key of its choosing, because it writes none of the places those come from. The trust anchor is the cache's signing key and the store that hands out its public key, as for every other credential.

Mixed versions on one host. hive-priv and hive-c0re restart separately. A new hive-c0re with an older hive-priv: the older one refuses an apply of a path it didn't download, so hive-c0re answers that refusal by sending the older RealiseClosure, which downloads and pins the path (already in the store), and applies again. A new hive-priv with an older hive-c0re: it still accepts RealiseClosure, answers Ok for a path already in the store with the swarm's signature and not available for one that isn't, and applies. Both shims go in the release after this one.

Container allowlist — hive-priv validates every request against an allowlist before any operation: the allowlist accepts only names matching the agent-name convention (h- prefix, char-validated) and rejects every other container name, the swarm's hive-forge and hive-matrix included: those belong to the host that runs them, not to a hive. hive-gateway is a host unit, not a container, so it's not in this list — see ReloadGatewayNginx above for how the hard-coded unit name scopes its access instead.

Socket-activated — systemd starts hive-priv on the first incoming connection (LISTEN_FDS=1); it's not running between calls. The ProtectSystem=strict + ReadWritePaths sandbox limits filesystem writes to only the paths hive-priv legitimately needs. The unit also sets LockPersonality, RestrictRealtime, ProtectKernelLogs and ProtectKernelModules: it changes no execution domain, schedules nothing in real time, reads no kernel log, and loads no module. It sets none of RestrictNamespaces (nsenter into a container needs setns), RestrictAddressFamilies, PrivateDevices, CapabilityBoundingSet or SystemCallFilter; those need a watched deploy to judge.

Privilege boundary summary

Component Runs as Privilege needed for
hive-c0re hive-core broker, HTTP dashboard
hive-priv root container lifecycle, bind mounts, cred writes
hive-ag3nt (per-container) per-agent user turn execution, MCP serving