boundary.md keeps the model, the design rule, why network isolation matters and host admin socket access; the hive-priv socket activation and the per-agent socket directory move to 4-implementation/trust-boundary. security.md keeps the agent trust model and the path-name rule; the nix builds mode with its threat model, and the hive-c0re privilege separation, move to their own pages there. Headings and text move as they were, with one level shaved off the heading depth. Links, code and nix pointers, and the docs index follow the moves.
317 lines
15 KiB
Nix
317 lines
15 KiB
Nix
# Per-agent unix user: the `services.hyperhive.agent.user.*` options, the user/group
|
|
# declarations, passwordless sudo, and the first-boot migration that
|
|
# chowns the bind-mounted state dirs to the agent user.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
userName = config.services.hyperhive.agent.user.name;
|
|
homeDir = "/home/${userName}";
|
|
in
|
|
{
|
|
# Per-agent unix user the harness + co-process daemons run as.
|
|
# Defaults to `"agent"` so a standalone evaluation (e.g.
|
|
# `nix flake check` against `nixosConfigurations.agent-base`) builds
|
|
# cleanly; the meta-flake's per-agent module rebinds this to the
|
|
# agent name (`"damocles"`, `"iris"`, …) so each container has a
|
|
# uniquely-named user matching its agent label. UID auto-assigned
|
|
# by NixOS (the auto-allocation range for normal users); no hard-
|
|
# coded UID.
|
|
options.services.hyperhive.agent.user.name = lib.mkOption {
|
|
type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$";
|
|
default = "agent";
|
|
example = "iris";
|
|
description = ''
|
|
Unix user the harness service runs as inside the container.
|
|
The meta-flake overrides this to the agent's own name so the
|
|
user inside the container matches the agent label (`HIVE_LABEL`).
|
|
Stand-alone evaluation defaults to `"agent"` so module evaluation
|
|
without the meta-flake wrapper still builds.
|
|
|
|
Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start,
|
|
total length ≤ 31, no special characters. UID is auto-assigned
|
|
by NixOS unless `services.hyperhive.agent.user.uid` is explicitly set.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.agent.user.uid = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.int;
|
|
default = null;
|
|
example = 1100;
|
|
description = ''
|
|
Optional fixed UID for the per-agent unix user. `null` (default)
|
|
lets NixOS auto-assign from the normal-user range (≥ 1000),
|
|
which is the right default for most deployments — the UID stays
|
|
stable across container rebuilds because each container only has
|
|
one normal user and the assignment is written into the container's
|
|
`/etc/passwd` at activation time.
|
|
|
|
Set an explicit value only when the host needs a predictable UID
|
|
for the agent's state files — e.g. if an operator script
|
|
references files by numeric UID, or to keep ownership stable
|
|
across full container destroy + recreate on a fresh host.
|
|
|
|
Values must be in `[1000, 60000)`. Using UIDs < 1000 clashes with
|
|
system accounts and is rejected by NixOS.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.agent.user.gid = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.int;
|
|
default = null;
|
|
example = 1100;
|
|
description = ''
|
|
Optional fixed GID for the per-agent unix group. `null` (default)
|
|
lets NixOS auto-assign. Usually set alongside `services.hyperhive.agent.user.uid`
|
|
to the same value (the conventional Unix pattern for per-user
|
|
groups where uid == gid), but can be set independently.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.agent.user.passwordlessSudo = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
example = false;
|
|
description = ''
|
|
Grant `${config.services.hyperhive.agent.user.name}` passwordless sudo
|
|
(`NOPASSWD: ALL`). True by default so claude's `Bash` tool
|
|
keeps working for tools that expect root inside the container
|
|
(`systemctl`, package managers in dev shells, etc.) — the
|
|
same surface the previous root-user shape had, just elevated
|
|
explicitly instead of implicitly.
|
|
|
|
Flip to `false` for agents that should be strictly
|
|
unprivileged. Anything claude shells out to that needs root
|
|
will then fail loudly with the standard sudo error rather
|
|
than silently succeeding — easier to spot the leak.
|
|
'';
|
|
};
|
|
|
|
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
|
|
|| (
|
|
config.services.hyperhive.agent.user.uid >= 1000 && config.services.hyperhive.agent.user.uid < 60000
|
|
);
|
|
message = ''
|
|
services.hyperhive.agent.user.uid must be in [1000, 60000) — values below
|
|
1000 clash with system accounts; values ≥ 60000 are reserved
|
|
by NixOS for dynamic allocation. Leave unset (null) to let
|
|
NixOS auto-assign.
|
|
'';
|
|
}
|
|
{
|
|
assertion =
|
|
config.services.hyperhive.agent.user.gid == null
|
|
|| (
|
|
config.services.hyperhive.agent.user.gid >= 1000 && config.services.hyperhive.agent.user.gid < 60000
|
|
);
|
|
message = ''
|
|
services.hyperhive.agent.user.gid must be in [1000, 60000) — same range
|
|
constraint as services.hyperhive.agent.user.uid.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# The container activation script (hive-agent-user-migrate) chowns
|
|
# the bind-mounted state dir — including credential files written
|
|
# by hive-c0re before the container was built — to this user on
|
|
# every boot, so agent processes can always read their own tokens.
|
|
users.users.${userName} = {
|
|
isNormalUser = true;
|
|
home = homeDir;
|
|
createHome = true;
|
|
group = userName;
|
|
extraGroups = lib.optional config.services.hyperhive.agent.user.passwordlessSudo "wheel";
|
|
# Matches /bin/bash on NixOS — the harness's claude shell-outs
|
|
# expect a POSIX shell at $SHELL; bashInteractive is already
|
|
# the system default for the root user too.
|
|
shell = pkgs.bashInteractive;
|
|
}
|
|
// lib.optionalAttrs (config.services.hyperhive.agent.user.uid != null) {
|
|
uid = config.services.hyperhive.agent.user.uid;
|
|
};
|
|
users.groups.${userName} =
|
|
{ }
|
|
// lib.optionalAttrs (config.services.hyperhive.agent.user.gid != null) {
|
|
gid = config.services.hyperhive.agent.user.gid;
|
|
};
|
|
|
|
# nix keeps a non-root user's profile under `~/.local/state/nix`, and the
|
|
# root is rebuilt at every start. Redirecting that directory into the
|
|
# per-container `/nix/var/nix/profiles` bind keeps what the agent installs
|
|
# (profile generations and the GC roots that point at them) across restarts.
|
|
# `~/.nix-profile` is the link nix makes on first use; recreating it puts
|
|
# the profile's `bin/` back on PATH before nix runs again.
|
|
systemd.tmpfiles.rules =
|
|
let
|
|
persisted = "/nix/var/nix/profiles/per-user/${userName}";
|
|
owner = "${userName} ${userName}";
|
|
in
|
|
[
|
|
"d /nix/var/nix/profiles/per-user 0755 root root - -"
|
|
"d ${persisted} 0755 ${owner} - -"
|
|
"d ${homeDir}/.local 0755 ${owner} - -"
|
|
"d ${homeDir}/.local/state 0755 ${owner} - -"
|
|
"L ${homeDir}/.local/state/nix - - - - ${persisted}"
|
|
"L ${homeDir}/.nix-profile - - - - ${homeDir}/.local/state/nix/profiles/profile"
|
|
]
|
|
# A namespaced agent's socket dir is bound at /agents/<name>/sockets, not
|
|
# under /run, whose tmpfs nspawn mounts before the binds and so owns the
|
|
# mount point to root (hive-c0re `socket_bind`). The harness keeps the
|
|
# /run/hive-agent path. A dir already bound there is left alone.
|
|
++ lib.optional config.services.hyperhive.agent.user.privateUsers "L /run/hive-agent/${userName} - - - - /agents/${userName}/sockets";
|
|
|
|
# `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool
|
|
# keep working with anything that expected root (systemctl,
|
|
# nix-env, etc.) without prompting. Flip
|
|
# `services.hyperhive.agent.user.passwordlessSudo = false` to drop both
|
|
# the wheel-group membership and this sudoers entry; anything
|
|
# that needs root then fails loudly instead of silently
|
|
# succeeding.
|
|
security.sudo.extraRules = lib.mkIf config.services.hyperhive.agent.user.passwordlessSudo [
|
|
{
|
|
users = [ userName ];
|
|
commands = [
|
|
{
|
|
command = "ALL";
|
|
options = [ "NOPASSWD" ];
|
|
}
|
|
];
|
|
}
|
|
];
|
|
|
|
# First-boot migration to the per-agent unix user — creates the
|
|
# home dir, chowns the bind-mounted state + `~/.claude/`, and
|
|
# (marker-guarded) moves any leftover `/root/.claude` content
|
|
# from the previous root-run shape. See
|
|
# `docs/2-handbook/agent-lifecycle/persistence.md::First-boot agent-user migration` for the
|
|
# step-by-step rationale; this script implements it.
|
|
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
|
|
homeDir=${lib.escapeShellArg homeDir}
|
|
userName=${lib.escapeShellArg userName}
|
|
mkdir -p "$homeDir"
|
|
chown "$userName:$userName" "$homeDir"
|
|
# The marker is only written once nothing is left to migrate — either
|
|
# there was nothing under /root/.claude, or `cp` copied it all. A
|
|
# failed `cp` (disk full, permission error) leaves the marker absent,
|
|
# so the next boot's activation retries; `-an` never clobbers a file
|
|
# this attempt already placed, so a retry after a partial copy is
|
|
# exactly as safe as the first attempt.
|
|
marker=/var/lib/hive-agent-user-migrated
|
|
if [ ! -e "$marker" ]; then
|
|
if [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
|
|
mkdir -p "$homeDir/.claude"
|
|
if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then
|
|
rm -rf /root/.claude
|
|
echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude"
|
|
mkdir -p "$(dirname "$marker")"
|
|
: > "$marker"
|
|
else
|
|
echo "hive-agent-user-migrate: copying /root/.claude to $homeDir/.claude failed; will retry next boot" >&2
|
|
fi
|
|
else
|
|
mkdir -p "$(dirname "$marker")"
|
|
: > "$marker"
|
|
fi
|
|
fi
|
|
# Recursive chown of a dir the agent must own; never fails the script.
|
|
# A failure logs chown's first stderr line plus the line count, since a
|
|
# walk over a large tree can emit one line per file.
|
|
heal_own_dir() {
|
|
healErr=$(chown -hR "$userName:$userName" "$1" 2>&1) \
|
|
|| echo "hive-agent-user-migrate: could not chown $1 to $userName ($(printf '%s\n' "$healErr" | wc -l) error lines, first: $(printf '%s\n' "$healErr" | head -n 1))" >&2
|
|
}
|
|
# Scope state + harness + work chowns to THIS container's own dirs only.
|
|
# The glob `/agents/*/state` also matches other agents' state dirs
|
|
# bind-mounted into a `ManageRootAgent` holder's container, which
|
|
# would clobber the ownership those dirs' own activation scripts set
|
|
# — producing intermittent EACCES for that agent's harness between
|
|
# the holder's rebuild and its own next activation.
|
|
if [ -d "/agents/$userName/state" ]; then
|
|
heal_own_dir "/agents/$userName/state"
|
|
fi
|
|
if [ -d "/agents/$userName/harness" ]; then
|
|
heal_own_dir "/agents/$userName/harness"
|
|
fi
|
|
# work/ holds large build trees and the agent creates everything in
|
|
# it, so a full walk is only needed when the top dir is not already
|
|
# the agent's: that covers a dir hive-c0re just created as hive-core
|
|
# and an agent uid that changed. Otherwise activation does one stat.
|
|
if [ -d "/agents/$userName/work" ]; then
|
|
if [ "$(stat -c %U "/agents/$userName/work")" != "$userName" ]; then
|
|
heal_own_dir "/agents/$userName/work"
|
|
fi
|
|
fi
|
|
# The socket dir is bind-mounted from the host, where hive-priv creates
|
|
# it `0751 root`. The harness binds its sockets here as this user; the
|
|
# 0751 is what lets hive-c0re and nginx dial them without listing
|
|
# (docs/4-implementation/trust-boundary/boundary-mechanism.md, "the per-agent socket dir"). Not
|
|
# recursive: only the harness writes inside it. `test -d` and `chmod`
|
|
# follow symlinks, so a link here is refused rather than handed over.
|
|
socketDir="/run/hive-agent/$userName"
|
|
if [ -L "$socketDir" ] || { [ -e "$socketDir" ] && [ ! -d "$socketDir" ]; }; then
|
|
echo "hive-agent-user-migrate: $socketDir is a symlink or not a directory; not handing it to $userName" >&2
|
|
elif [ -d "$socketDir" ]; then
|
|
chown -h "$userName:$userName" "$socketDir" \
|
|
&& chmod 0751 "$socketDir" \
|
|
|| echo "hive-agent-user-migrate: could not hand $socketDir to $userName; the harness cannot bind its sockets" >&2
|
|
fi
|
|
if [ -d "$homeDir/.claude" ]; then
|
|
heal_own_dir "$homeDir/.claude"
|
|
# 0755 so hive-core (a different unix user) can list the dir and
|
|
# detect a valid claude session. Credential files inside are 0600
|
|
# so secrets stay private regardless of the directory mode.
|
|
# ensure_claude_dir sets 0755 on creation but cannot re-chmod after
|
|
# hive-agent-user-migrate chowns the dir to the agent user; this
|
|
# activation script runs as root and handles the correction.
|
|
chmod 755 "$homeDir/.claude" \
|
|
|| echo "hive-agent-user-migrate: could not chmod 755 $homeDir/.claude; hive-core cannot detect the claude session" >&2
|
|
fi
|
|
'';
|
|
};
|
|
}
|