diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 36704eeb..40d92c05 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -214,11 +214,15 @@ new containers and restarts pick the values up immediately. Drop into an interactive Claude session inside an agent container. Replaces the current process with `machinectl shell @h-` -running claude from the agent's state dir. Requires root (same as all -`machinectl shell` operations) — hyperhive ships no polkit rule granting -those actions to the operator group, so `choom` refuses up front with a -message naming that requirement rather than letting systemd reject the -exec later. +running claude from the agent's state dir. Requires root, or membership in +`hive-admin` (the same sudoless-`hivectl` group as the [host admin +socket](../trust-boundary/boundary.md) — hive-c0re ships a polkit rule +granting that group the one action `machinectl shell` needs); `choom` +checks this itself and refuses up front with a message naming the +requirement rather than letting systemd reject the exec later with an +opaque polkit prompt. Same caveat as the socket grant: a shell opened +before you were added to `hive-admin` won't see it until you log back in +— secondary group membership applies at login. It also needs the daemon socket, unlike the other exec-into-a-container paths: the "is this actually an agent?" pre-flight reads the agents root, diff --git a/hivectl/src/choom.rs b/hivectl/src/choom.rs index 42ce0b18..4880ea1b 100644 --- a/hivectl/src/choom.rs +++ b/hivectl/src/choom.rs @@ -10,21 +10,65 @@ use anyhow::{Result, bail}; use crate::util::agent_exists; -/// Refuse early when the caller isn't root. +/// Whether the caller's supplementary groups include `hive-admin`. /// -/// `machinectl shell` needs root or a polkit grant, and hyperhive ships -/// no polkit rules — so unprivileged callers hit an authentication -/// prompt/refusal from systemd that says nothing about which hivectl -/// verb wanted it. Naming the requirement here keeps the failure honest -/// and points at the one thing that would fix it. -fn require_root() -> Result<()> { - // SAFETY: `geteuid` takes no arguments, reads a process attribute - // the kernel always has, and cannot fail. +/// hive-c0re's polkit rule (`nix/host-modules/hive-c0re/default.nix`) grants +/// `org.freedesktop.machine1.shell` to this group, so a member reaches the +/// same outcome as root without one. `getgrnam`/`getgroups` read the +/// *process's* actual credentials, the same ones the kernel would check — +/// not a fresh `/etc/group` lookup by name — so this can say no for a user +/// who was just added to the group but hasn't logged back in yet, same +/// caveat as the host-socket grant (`hivectl/src/client.rs::connect_hint`). +fn in_hive_admin_group() -> bool { + // SAFETY: `getgrnam` takes a valid NUL-terminated string literal and + // returns either null (no such group) or a pointer to static storage + // owned by libc, which is read once, immediately, before any other + // libc call could invalidate it. + let gid = unsafe { + let grp = libc::getgrnam(c"hive-admin".as_ptr()); + if grp.is_null() { + return false; + } + (*grp).gr_gid + }; + // SAFETY: a zero-length call with a null buffer is documented to return + // the caller's supplementary-group count without writing anything, so + // the real buffer below is sized to exactly what the second call needs. + let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; + let Ok(count) = usize::try_from(count) else { + return false; + }; + let mut groups = vec![0u32; count]; + let Ok(capacity) = i32::try_from(groups.len()) else { + return false; + }; + // SAFETY: `capacity` is exactly `groups.len()`, so this write cannot + // overflow the buffer. + let n = unsafe { libc::getgroups(capacity, groups.as_mut_ptr()) }; + let Ok(n) = usize::try_from(n) else { + return false; + }; + groups.truncate(n); + groups.contains(&gid) +} + +/// Refuse early when the caller can't actually run `machinectl shell`. +/// +/// Root always can. So can a `hive-admin` member, via the polkit grant +/// above. Anyone else hits an opaque polkit authentication prompt/refusal +/// from systemd that names neither hivectl nor the fix — naming both here +/// instead is the entire point of this check. +fn require_privilege() -> Result<()> { + // SAFETY: `geteuid` takes no arguments, reads a process attribute the + // kernel always has, and cannot fail. let euid = unsafe { libc::geteuid() }; - if euid != 0 { + if euid != 0 && !in_hive_admin_group() { bail!( - "choom needs root: it runs `machinectl shell`, which requires root \ - (hyperhive ships no polkit rule granting it) - re-run with sudo" + "choom needs root or `hive-admin` group membership: it runs `machinectl shell`, \ + gated by a polkit rule for that group. If you were just added to `hive-admin`, \ + log out and back in first — secondary group membership applies at login, so a \ + shell opened before the change still won't have it. `id -nG` shows what your \ + running shell actually has" ); } Ok(()) @@ -36,8 +80,8 @@ fn require_root() -> Result<()> { /// user from its state dir, reproducing the harness's per-turn claude /// invocation (flags, session selection, why it never collides with the /// live harness session). See `docs/tools/hivectl.md` (Choom) for the -/// full rationale. Inherits the caller's PTY; requires root + a running -/// container. +/// full rationale. Inherits the caller's PTY; requires root or `hive-admin` +/// group membership, plus a running container. /// /// The existence check goes over the daemon socket even though the exec /// itself doesn't need it: the agents root isn't readable by the @@ -50,7 +94,7 @@ pub(crate) async fn choom(socket: &Path, name: &str, resume_session: Option<&str hive_host_sock::AGENTS_ROOT ); } - require_root()?; + require_privilege()?; let container = hive_host_sock::container_name(name); // Enter as the agent's unix user (== agent name) so claude reads the // right `$HOME/.claude`. diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 4d71426e..83c753f4 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -229,6 +229,27 @@ in members = cfg.adminUsers; }; + # Let `hive-admin` members run `hivectl agent choom` + # (`machinectl shell`) without sudo, same opt-in-by-group shape as the + # socket grant above. `machinectl shell @h-` triggers exactly + # one polkit action, `org.freedesktop.machine1.shell` ("Acquire a shell + # in a local container") — not `.login` (a different verb, `machinectl + # login`, which `choom` never calls) and not `.host-shell` (the + # host-target variant, for a bare `machinectl shell` with no `@machine` + # suffix; `choom` always targets `@h-`). `enable = true` is + # required here: `security.polkit.extraConfig` is silently dropped + # unless the module itself is turned on, and nothing else in a headless + # hive pulls polkit in the way a desktop session would. + security.polkit.enable = true; + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.machine1.shell" && + subject.isInGroup("hive-admin")) { + return polkit.Result.YES; + } + }); + ''; + # The gateway nginx is always the sole external entry point (it runs # alongside hyperhive), so the per-agent web-port range stays closed on # the host firewall. See `docs/networking/gateway.md::Firewall posture (host-level)`.