mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
When operators ran `docker exec <c> hermes login` (or anything else that wrote under $HERMES_HOME) they defaulted to root, leaving /opt/data/auth.json root:root mode 0600. The supervised gateway (UID 10000) then couldn't read its own credentials and returned "Provider authentication failed: Hermes is not logged into Nous Portal" on every Telegram/Discord/etc. message — even though `docker exec <c> hermes chat -q ping` (also root) succeeded because root could read its own root-owned file. _load_auth_store swallowed PermissionError as a parse failure and copied the file aside as auth.json.corrupt, making the diagnostic more misleading. Fix: install a privilege-drop shim at /opt/hermes/bin/hermes, prepended ahead of the venv on PATH. When invoked as root the shim exec's the real venv binary via `s6-setuidgid hermes` — so any file the docker-exec session writes is uid-aligned with the supervised processes. Non-root callers (the supervised processes themselves, `docker exec --user hermes`, kanban subagents, anything inside the container that's not coming through docker-exec) hit a single exec to the absolute venv path with no privilege change. Recursion is impossible: the shim exec's the venv binary by absolute path (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter the shim regardless of PATH state. No sentinel env var needed (unlike #33583's gateway-run redirect which DOES need HERMES_S6_SUPERVISED_CHILD because there's no absolute-path equivalent for the s6 dispatch). Opt-out: `docker exec -e HERMES_DOCKER_EXEC_AS_ROOT=1 …` for diagnostic sessions where the operator deliberately wants root. Strict truthiness (1/true/yes case-insensitive); typos like `=0` do not silently opt out, mirroring HERMES_GATEWAY_NO_SUPERVISE in #33583. If `s6-setuidgid` is missing (someone stripped s6-overlay in a downstream fork), the shim exits 126 with a remediation message pointing at `--user hermes` and the opt-out — never silently runs as root. Test plan: - tests/docker/test_docker_exec_privilege_drop.py — 11 tests - shim drops root to hermes uid (file ownership check) - shim short-circuits for non-root docker exec - HERMES_DOCKER_EXEC_AS_ROOT=1 keeps root - strict-truthiness parametrization (5 falsy values reject) - main CMD path unaffected (recursion guard) - E2E: every file written by docker-exec is readable by uid 10000 - Full tests/docker/ harness: 32/32 pass against fresh image build - shellcheck --severity=error: clean - hadolint: clean - Manual: reproduced the original symptom (root-owned auth.json) by bypassing the shim; confirmed default docker-exec produces hermes-owned files; confirmed opt-out env keeps root semantics. Known follow-up: this prevents NEW instances of the bug. Volumes that already have root:root /opt/data/auth.json from a pre-shim image need a one-time `chown hermes:hermes` before rebooting onto the new image. A stage2-hook chown sweep can self-heal that, but is deferred per scope decision.
87 lines
3.6 KiB
Bash
87 lines
3.6 KiB
Bash
#!/bin/sh
|
|
# shellcheck shell=sh
|
|
# /opt/hermes/bin/hermes — `docker exec` privilege-drop shim.
|
|
#
|
|
# Background
|
|
# ----------
|
|
# The s6 image runs the supervised gateway/main process as the unprivileged
|
|
# `hermes` user (UID 10000). When an operator runs `docker exec <c> hermes ...`
|
|
# the default UID is root (0), and any file the command writes under
|
|
# $HERMES_HOME — auth.json, .env, config.yaml — ends up root-owned and
|
|
# unreadable to the supervised gateway. The most common manifestation: the
|
|
# user runs `docker exec <c> hermes login`, this writes
|
|
# /opt/data/auth.json as root:root mode 0600, and from then on the gateway
|
|
# returns "Provider authentication failed: Hermes is not logged into Nous
|
|
# Portal" on every incoming message — even though `docker exec <c> hermes
|
|
# chat -q ping` (also running as root) succeeds because root happens to be
|
|
# able to read its own root-owned file. See systematic-debugging skill
|
|
# notes attached to this fix.
|
|
#
|
|
# Fix
|
|
# ---
|
|
# This shim sits at /opt/hermes/bin/hermes and is placed earliest on PATH.
|
|
# When invoked as root, it drops to the hermes user (via s6-setuidgid)
|
|
# before exec'ing the real venv binary, so anything that writes under
|
|
# $HERMES_HOME is uid-aligned with the supervised processes. When invoked
|
|
# as any non-root UID — including the supervised processes themselves,
|
|
# `docker exec --user hermes`, kanban subagents, etc. — it short-circuits
|
|
# straight to the venv binary with no privilege change. Net: one extra
|
|
# fork on the docker-exec-as-root path, zero behavioral change on every
|
|
# other path.
|
|
#
|
|
# Recursion safety: the shim exec's the venv binary by *absolute path*
|
|
# (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter this
|
|
# shim regardless of PATH state. No sentinel env var needed.
|
|
#
|
|
# Opt-out: set HERMES_DOCKER_EXEC_AS_ROOT=1 (1/true/yes, case-insensitive)
|
|
# to keep running as root. Reserved for diagnostic sessions where the
|
|
# operator deliberately wants root semantics — e.g. inspecting root-only
|
|
# state via the hermes CLI. Default is to drop.
|
|
|
|
set -e
|
|
|
|
REAL=/opt/hermes/.venv/bin/hermes
|
|
|
|
# Defensive: if the venv binary is missing (corrupted image, partial
|
|
# install), fail loudly rather than silently masking it.
|
|
if [ ! -x "$REAL" ]; then
|
|
echo "hermes-shim: $REAL not found or not executable" >&2
|
|
exit 127
|
|
fi
|
|
|
|
# Already non-root? Just exec the real binary. This is the hot path for
|
|
# supervised processes (uid 10000) and for `docker exec --user hermes`.
|
|
if [ "$(id -u)" != "0" ]; then
|
|
exec "$REAL" "$@"
|
|
fi
|
|
|
|
# Root, with opt-out set? Honor it.
|
|
case "${HERMES_DOCKER_EXEC_AS_ROOT:-}" in
|
|
1|true|TRUE|True|yes|YES|Yes)
|
|
exec "$REAL" "$@"
|
|
;;
|
|
esac
|
|
|
|
# Root, no opt-out. Drop to the hermes user.
|
|
#
|
|
# s6-setuidgid lives under /command/ which is NOT on `docker exec`'s PATH
|
|
# (s6-overlay only puts /command/ on PATH for supervision-tree children).
|
|
# Reference it by absolute path so the drop is robust against PATH
|
|
# manipulation.
|
|
S6_SUID=/command/s6-setuidgid
|
|
if [ ! -x "$S6_SUID" ]; then
|
|
# Non-s6 image (someone stripped s6-overlay, or a hand-built variant).
|
|
# Fail loud rather than silently re-execing as root and leaking the
|
|
# bug this shim exists to prevent.
|
|
echo "hermes-shim: $S6_SUID not found; refusing to silently run as root." >&2
|
|
echo "hermes-shim: re-run with --user hermes or set HERMES_DOCKER_EXEC_AS_ROOT=1." >&2
|
|
exit 126
|
|
fi
|
|
|
|
# Reset HOME to the hermes user's home before dropping privileges. Without
|
|
# this, $HOME stays /root and any library that resolves paths off $HOME
|
|
# (XDG caches, lockfiles, .config writes) will try to write to /root and
|
|
# fail with EACCES. Mirrors main-wrapper.sh.
|
|
export HOME=/opt/data
|
|
|
|
exec "$S6_SUID" hermes "$REAL" "$@"
|