From 1fd591d28b1b2220c57aa73226246b6e5fb9eac6 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 1 Nov 2026 16:05:36 +0100 Subject: [PATCH] add the agent option privateUsers, with the uid = gid assertion --- nix/agent-modules/user.nix | 39 ++++++++++++++++++++++++++++++++++ nix/module-eval/agent-user.nix | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/nix/agent-modules/user.nix b/nix/agent-modules/user.nix index 6574b163..6498223c 100644 --- a/nix/agent-modules/user.nix +++ b/nix/agent-modules/user.nix @@ -90,8 +90,47 @@ in ''; }; + options.services.hyperhive.agent.user.privateUsers = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Run this agent's container in its own user namespace. Off by default. + + **Approving the config PR that sets this authorises a host-level + change.** The swarm builder reads the option from the evaluated config + into the agent's closure record, and the hive then starts the container + with `--private-users=pick`, every hive bind mounted `owneridmap`, and + its root idmapped. The first such start has `hive-priv`, as root, chown + the agent's `state`, `harness`, `work` and `claude` dirs once to the + host's data owner + (`services.hyperhive.deploy.hive-controller.userns.dataOwner`). Nothing is + deleted. Turning it off afterwards needs the dirs chowned back by hand. + + Needs `user.uid` and `user.gid` set to the same number: nspawn maps a + bind's gids with its uid map, so a group of another number has no + mapping and writes fail. Start the agent once with the pin before + turning this on; `hive-priv` refuses to move a `state` dir whose gid + differs from its uid. An agent that holds `ManageRootAgent` should not + set it, since the other agents' dirs it mounts are not remapped. + ''; + }; + config = { assertions = [ + { + assertion = + !config.services.hyperhive.agent.user.privateUsers + || ( + config.services.hyperhive.agent.user.uid != null + && config.services.hyperhive.agent.user.uid == config.services.hyperhive.agent.user.gid + ); + message = '' + services.hyperhive.agent.user.privateUsers needs services.hyperhive.agent.user.uid + and services.hyperhive.agent.user.gid set to the same number: a user namespace + maps a bind's gids with its uid map, so a group of another number has no mapping. + ''; + } { assertion = config.services.hyperhive.agent.user.uid == null diff --git a/nix/module-eval/agent-user.nix b/nix/module-eval/agent-user.nix index f6c03fff..dbbc6cc8 100644 --- a/nix/module-eval/agent-user.nix +++ b/nix/module-eval/agent-user.nix @@ -27,7 +27,44 @@ let .system.activationScripts.hive-agent-user-migrate.text; # The tmpfiles rules of the same named agent. rules = (agentWith { services.hyperhive.agent.user.name = "iris"; }).systemd.tmpfiles.rules; + # `privateUsers` with the given pins; the agent name is the one the cases + # above use, so only the pins differ. + withUsers = + user: + agentWith { + services.hyperhive.agent.user = { + name = "iris"; + } + // user; + }; + # Whether the module refuses the config over the user-namespace pin. + refusesPins = + cfg: lib.any (a: !a.assertion && lib.hasInfix "privateUsers" a.message) cfg.assertions; cases = [ + { + # The control is the matching pair: it passes, so each refusal is the + # assertion's own and the default (off, no pins) passes too. + name = "privateUsers needs a uid and a gid that are the same number"; + ok = + !refusesPins (withUsers { }) + && !refusesPins (withUsers { + privateUsers = true; + uid = 1000; + gid = 1000; + }) + && refusesPins (withUsers { + privateUsers = true; + }) + && refusesPins (withUsers { + privateUsers = true; + uid = 1000; + }) + && refusesPins (withUsers { + privateUsers = true; + uid = 1000; + gid = 1001; + }); + } { # hive-c0re creates the host work dir as hive-core; this activation is # the only thing that lets the agent write to it.