hive-c0re: grant hive-admin group a polkit rule for choom

This commit is contained in:
damocles 2026-09-07 23:27:15 +02:00
commit 66c3138dd1
3 changed files with 89 additions and 20 deletions

View file

@ -214,11 +214,15 @@ new containers and restarts pick the values up immediately.
Drop into an interactive Claude session inside an agent container. Drop into an interactive Claude session inside an agent container.
Replaces the current process with `machinectl shell <name>@h-<name>` Replaces the current process with `machinectl shell <name>@h-<name>`
running claude from the agent's state dir. Requires root (same as all running claude from the agent's state dir. Requires root, or membership in
`machinectl shell` operations) — hyperhive ships no polkit rule granting `hive-admin` (the same sudoless-`hivectl` group as the [host admin
those actions to the operator group, so `choom` refuses up front with a socket](../trust-boundary/boundary.md) — hive-c0re ships a polkit rule
message naming that requirement rather than letting systemd reject the granting that group the one action `machinectl shell` needs); `choom`
exec later. 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 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, paths: the "is this actually an agent?" pre-flight reads the agents root,

View file

@ -10,21 +10,65 @@ use anyhow::{Result, bail};
use crate::util::agent_exists; 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 /// hive-c0re's polkit rule (`nix/host-modules/hive-c0re/default.nix`) grants
/// no polkit rules — so unprivileged callers hit an authentication /// `org.freedesktop.machine1.shell` to this group, so a member reaches the
/// prompt/refusal from systemd that says nothing about which hivectl /// same outcome as root without one. `getgrnam`/`getgroups` read the
/// verb wanted it. Naming the requirement here keeps the failure honest /// *process's* actual credentials, the same ones the kernel would check —
/// and points at the one thing that would fix it. /// not a fresh `/etc/group` lookup by name — so this can say no for a user
fn require_root() -> Result<()> { /// who was just added to the group but hasn't logged back in yet, same
// SAFETY: `geteuid` takes no arguments, reads a process attribute /// caveat as the host-socket grant (`hivectl/src/client.rs::connect_hint`).
// the kernel always has, and cannot fail. 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() }; let euid = unsafe { libc::geteuid() };
if euid != 0 { if euid != 0 && !in_hive_admin_group() {
bail!( bail!(
"choom needs root: it runs `machinectl shell`, which requires root \ "choom needs root or `hive-admin` group membership: it runs `machinectl shell`, \
(hyperhive ships no polkit rule granting it) - re-run with sudo" 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(()) Ok(())
@ -36,8 +80,8 @@ fn require_root() -> Result<()> {
/// user from its state dir, reproducing the harness's per-turn claude /// user from its state dir, reproducing the harness's per-turn claude
/// invocation (flags, session selection, why it never collides with the /// invocation (flags, session selection, why it never collides with the
/// live harness session). See `docs/tools/hivectl.md` (Choom) for the /// live harness session). See `docs/tools/hivectl.md` (Choom) for the
/// full rationale. Inherits the caller's PTY; requires root + a running /// full rationale. Inherits the caller's PTY; requires root or `hive-admin`
/// container. /// group membership, plus a running container.
/// ///
/// The existence check goes over the daemon socket even though the exec /// 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 /// 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 hive_host_sock::AGENTS_ROOT
); );
} }
require_root()?; require_privilege()?;
let container = hive_host_sock::container_name(name); let container = hive_host_sock::container_name(name);
// Enter as the agent's unix user (== agent name) so claude reads the // Enter as the agent's unix user (== agent name) so claude reads the
// right `$HOME/.claude`. // right `$HOME/.claude`.

View file

@ -229,6 +229,27 @@ in
members = cfg.adminUsers; members = cfg.adminUsers;
}; };
# Let `hive-admin` members run `hivectl agent <name> choom`
# (`machinectl shell`) without sudo, same opt-in-by-group shape as the
# socket grant above. `machinectl shell <name>@h-<name>` 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 `<name>@h-<name>`). `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 # The gateway nginx is always the sole external entry point (it runs
# alongside hyperhive), so the per-agent web-port range stays closed on # alongside hyperhive), so the per-agent web-port range stays closed on
# the host firewall. See `docs/networking/gateway.md::Firewall posture (host-level)`. # the host firewall. See `docs/networking/gateway.md::Firewall posture (host-level)`.