agents: drop root, run as per-agent unix user with passwordless sudo (#658)

This commit is contained in:
damocles 2026-05-30 21:14:46 +02:00 committed by Mara
commit 6b6c6775ee
10 changed files with 349 additions and 71 deletions

View file

@ -1,4 +1,7 @@
{ pkgs, config, ... }:
let
userName = config.hyperhive.user.name;
in
{
imports = [ ./harness-base.nix ];
@ -15,6 +18,11 @@
path = [ "/run/current-system/sw" ];
environment = {
SHELL = "${pkgs.bashInteractive}/bin/bash";
# `HOME` defaults to `/` for systemd services without a User=
# set. With #658 the harness runs as the agent user — set HOME
# explicitly so claude (which the harness spawns) finds its
# `~/.claude/` session dir at the bind-mounted location.
HOME = "/home/${userName}";
# Path to the merged agent static dist. The harness serves this
# via `tower_http::ServeDir` for any request it doesn't route to
# an API endpoint. `mergedDist` is the agent-default dist with
@ -30,6 +38,14 @@
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
Restart = "on-failure";
RestartSec = 2;
# Run the harness as the per-agent user (#658). claude itself
# spawned by the harness then runs as that user too — drops
# root inside the container while sudo (`NOPASSWD: ALL` by
# default, see harness-base.nix `hyperhive.user.passwordlessSudo`)
# keeps the previous root-by-default surface available
# explicitly for tools that need it.
User = userName;
Group = userName;
};
};
}

View file

@ -9,6 +9,15 @@
flakeInputs ? { },
...
}:
let
# Agent user metadata (#658). `userName` defaults to `"agent"` when
# the meta-flake doesn't inject the per-agent override (stand-alone
# `nixos-rebuild` against `nixosConfigurations.agent-base` works
# without erroring on a missing per-agent name). `homeDir` derives
# from `userName` to keep them coupled.
userName = config.hyperhive.user.name;
homeDir = "/home/${userName}";
in
{
# Shared scaffolding for any hyperhive harness container — both
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
@ -20,6 +29,52 @@
# only opts in from its own `agent.nix`.
imports = [ ./weston-vnc.nix ];
# Per-agent unix user the harness + co-process daemons run as (#658).
# 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.hyperhive.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; no `uid =` override surface (intentional pinning
across rebuilds isn't a concern when the home and state dirs
stay bind-mounted from the host).
'';
};
options.hyperhive.user.passwordlessSudo = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Grant `${config.hyperhive.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.hyperhive.model = lib.mkOption {
type = lib.types.str;
default = "haiku";
@ -353,23 +408,25 @@
};
options.hyperhive.dashboardLinks = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
label = lib.mkOption {
type = lib.types.str;
description = "Display label for the link.";
type = lib.types.listOf (
lib.types.submodule {
options = {
label = lib.mkOption {
type = lib.types.str;
description = "Display label for the link.";
};
icon = lib.mkOption {
type = lib.types.str;
default = "";
description = "Optional icon emoji or short glyph.";
};
url = lib.mkOption {
type = lib.types.str;
description = "Full URL (may include a different port, e.g. http://localhost:9001/stats).";
};
};
icon = lib.mkOption {
type = lib.types.str;
default = "";
description = "Optional icon emoji or short glyph.";
};
url = lib.mkOption {
type = lib.types.str;
description = "Full URL (may include a different port, e.g. http://localhost:9001/stats).";
};
};
});
}
);
default = [ ];
example = lib.literalExpression ''
[
@ -505,9 +562,7 @@
}
# hyperhive.icon must reference an SVG file when set.
{
assertion =
config.hyperhive.icon == null
|| lib.hasSuffix ".svg" (toString config.hyperhive.icon);
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
message = "hyperhive.icon must point to an .svg file";
}
# hyperhive.frontend.extraFiles[*].target is concatenated into
@ -519,9 +574,9 @@
# operator-reviewed, so this is belt-and-braces — but it's the
# kind of mistake that's easy to make and hard to spot.
{
assertion = lib.all (
entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))
) (lib.attrValues config.hyperhive.frontend.extraFiles);
assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) (
lib.attrValues config.hyperhive.frontend.extraFiles
);
message = ''
hyperhive.frontend.extraFiles: `target` must not contain
`..` path segments.
@ -529,6 +584,111 @@
}
];
# Per-agent unix user (#658). Runs the hive-ag3nt / hive-m1nd
# harness + co-process daemons (hive-matrix-daemon) under a
# non-root principal. The user name follows
# `hyperhive.user.name` — defaults to `"agent"` for standalone
# eval, overridden per-agent by the meta-flake to the agent's
# own label so each container has a uniquely-named user.
#
# UID auto-assigned by NixOS (per mara's #8109: "no hardcoded
# uids"). Home is `/home/${userName}`. `wheel` membership +
# the sudoers rule below grants `NOPASSWD: ALL` when
# `passwordlessSudo` is true — same blast radius as the
# previous root-by-default shape, just explicit.
users.users.${userName} = {
isNormalUser = true;
home = homeDir;
createHome = true;
group = userName;
extraGroups = lib.optional config.hyperhive.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 (see SHELL env
# var declaration below).
shell = pkgs.bashInteractive;
};
users.groups.${userName} = { };
# `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool
# keep working with anything that expected root (systemctl,
# nix-env, etc.) without prompting — same surface as the
# previous root-by-default shape, just elevated explicitly.
# Flip `hyperhive.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.hyperhive.user.passwordlessSudo [
{
users = [ userName ];
commands = [
{
command = "ALL";
options = [ "NOPASSWD" ];
}
];
}
];
# First-boot migration from the legacy root-run shape (#658).
# Runs on every activation; marker-guarded so the move only
# happens once. The bind mount that hive-c0re sets up has
# already moved from `/root/.claude` to `${homeDir}/.claude`
# by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT`
# — the host-side path stays the same, the container-side
# mount target shifts), so the bulk of the data is already at
# the new location. This script just:
#
# - ensures `${homeDir}` exists with correct ownership (covers
# the very first boot before useradd's `createHome` has
# anything to chown);
# - migrates any leftover `/root/.claude` content that an
# operator might have populated before #658 deployed (the
# bind mount didn't exist in that lifecycle, so claude
# would have written into the root user's empty home —
# nothing important typically, but safer to move than to
# strand);
# - chowns the bind-mounted state dir (`/agents/*/state`) so
# the agent user can read/write it.
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
homeDir=${lib.escapeShellArg homeDir}
userName=${lib.escapeShellArg userName}
# Always ensure the home dir exists with the right ownership —
# useradd's createHome handles the very first creation but
# doesn't re-chown if a rebuild changes the user name (rare
# but possible if the meta-flake's per-agent name evolves).
mkdir -p "$homeDir"
chown "$userName:$userName" "$homeDir"
# One-time migration of pre-#658 /root/.claude content into the
# new home. Marker-guarded so the move only runs once per
# container lifetime — subsequent activations skip the legacy
# path even if claude were to repopulate /root/.claude for any
# reason.
marker=/var/lib/hive-agent-user-migrated
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
mkdir -p "$homeDir/.claude"
# `mv -n` (no-clobber) so any pre-existing files at the
# destination (e.g. from the bind mount) win — we never
# blow over data already at the new location.
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"
fi
fi
mkdir -p "$(dirname "$marker")"
: > "$marker"
# Chown the bind-mounted state dir so the agent user can
# read/write it. `/agents/*/state` is the canonical mount
# point set by hive-c0re's `set_nspawn_flags`. Wildcard
# because each container only sees its own
# `/agents/<name>/state` (one match); -h to avoid following
# any symlinks the agent might have planted in there.
for stateDir in /agents/*/state; do
[ -d "$stateDir" ] || continue
chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true
done
'';
# Auto-inject the matrix MCP entry when matrix is enabled (#548
# phase 3). Operator can override or disable by setting their own
# `extraMcpServers.matrix` (nix submodule merge takes the operator's
@ -537,7 +697,11 @@
matrix = lib.mkDefault {
command = "${pkgs.hyperhive}/bin/hive-matrix-mcp";
args = [ ];
env = { };
# Same socket path the hive-matrix-daemon service binds
# via its `RuntimeDirectory = "hive-matrix"` (#658). Keeps
# the bridge + daemon in sync without baking the new path
# into the Rust default — the env override wins for both.
env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
allowedTools = [ "*" ];
};
};
@ -547,8 +711,9 @@
# Operator-set per-agent icon (hyperhive.icon). When configured, the
# SVG lands at /etc/hyperhive/icon.svg; the harness serves it at
# GET /icon, falling back to the bundled hyperhive logo when absent.
environment.etc."hyperhive/icon.svg" =
lib.mkIf (config.hyperhive.icon != null) { source = config.hyperhive.icon; };
environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) {
source = config.hyperhive.icon;
};
environment.etc."hyperhive/bash-allow.json".text =
builtins.toJSON config.hyperhive.allowedBashPatterns;
@ -600,13 +765,16 @@
HIVE_DEFAULT_MODEL = config.hyperhive.model;
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
SHELL = "${pkgs.bashInteractive}/bin/bash";
} // lib.optionalAttrs (!config.hyperhive.autoCompact) {
}
// lib.optionalAttrs (!config.hyperhive.autoCompact) {
# Zero watermark disables proactive compaction; the reactive path
# (compact-on-overflow) still fires when the session is truly full.
HIVE_COMPACT_WATERMARK_TOKENS = "0";
} // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions {
}
// lib.optionalAttrs config.hyperhive.forge.keepSubscriptions {
HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1";
} // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) {
}
// lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) {
HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons;
};
@ -680,6 +848,8 @@
pkgs.python3
pkgs.coreutils
];
environment.HOME_DIR = homeDir;
environment.AGENT_USER = userName;
script = ''
# No `set -e`: any subshell failure must not propagate.
# A failed unit aborts `nixos-container update` which blocks rebuilds.
@ -703,13 +873,16 @@
echo "tea-login: could not resolve username from forge API; skipping"
exit 0
fi
# tea reads config from ~/.config/tea/config.yml (for root: /root/.config/tea/config.yml).
# Write it directly so we control default:true and always
# refresh a rotated token — no 'tea login add' interactive dance.
# $HOME is unset in systemd service context (causing writes to
# /.config/). Hardcode /root — always correct for NixOS containers
# where the harness runs as root.
CONFIG="/root/.config/tea/config.yml"
# tea reads config from ~/.config/tea/config.yml. The
# agent user's home is $HOME_DIR (set by NixOS via
# hyperhive.user.name). Write the config under that
# home + chown to the agent user so tea reads it when
# invoked as that user. Still runs as root (this
# service stays root-owned to avoid bootstrap
# ordering issues — see comment near serviceConfig
# below), but the artefact it produces is for the
# agent user.
CONFIG="$HOME_DIR/.config/tea/config.yml"
mkdir -p "$(dirname "$CONFIG")" || true
cat > "$CONFIG" << EOF
logins:
@ -727,7 +900,8 @@
flag_defaults:
remote: ""
EOF
echo "tea-login: configured for $FORGE_URL as $USER"
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
'';
};
@ -808,16 +982,27 @@
wants = [ "network-online.target" ];
environment = {
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
# Socket path lives inside the systemd-managed runtime dir
# (`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`,
# owned by the agent user) so the daemon can bind it without
# needing root over `/run/` itself (#658). The stdio bridge
# picks up the same path via its own `HIVE_MATRIX_SOCKET` env
# in `extraMcpServers.matrix` below.
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
RUST_LOG = "info";
};
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
Restart = "on-failure";
RestartSec = 5;
# /run/hive-matrix.sock + the matrix-sdk-state sqlite dir
# don't need a StateDirectory= — the socket is on tmpfs (gone
# on restart, which is correct) and the sqlite dir lives in
# the bind-mounted agent state, mode-managed by the harness.
# Run as the per-agent unix user (#658). The runtime dir
# (`/run/hive-matrix/`) is owned by that user via
# `RuntimeDirectory`; claude (also as that user) can
# connect to the socket inside it when the stdio bridge
# spawns per turn.
User = userName;
Group = userName;
RuntimeDirectory = "hive-matrix";
};
};

View file

@ -1,4 +1,7 @@
{ pkgs, config, ... }:
let
userName = config.hyperhive.user.name;
in
{
imports = [ ./harness-base.nix ];
@ -24,6 +27,9 @@
HIVE_PORT = "8000";
HIVE_LABEL = "hm1nd";
SHELL = "${pkgs.bashInteractive}/bin/bash";
# `HOME` set explicitly so claude finds `~/.claude/` at the
# bind-mounted location after #658 (User= drop from root).
HOME = "/home/${userName}";
# Manager runs the same hive-m1nd harness binary that serves
# the per-agent web UI; point it at the merged agent static dist
# (same shape as for sub-agents).
@ -42,6 +48,13 @@
ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve";
Restart = "on-failure";
RestartSec = 2;
# Same drop-from-root as agent-base.nix (#658). Manager
# interactions with the host (rebuild approvals, config
# writes) still happen via the dedicated unix sockets
# bind-mounted from hive-c0re — those don't need root
# inside the container.
User = userName;
Group = userName;
};
};
}