Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ RUN set -eu; \
"$config_dir/workspace" \
"$config_dir/profiles" \
"$config_dir/cache" \
"$config_dir/pairing"; \
"$config_dir/pairing" \
"$config_dir/runtime"; \
if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then \
echo "ERROR: legacy data dir still exists after cleanup: $data_dir" >&2; \
exit 1; \
Expand All @@ -211,9 +212,28 @@ RUN set -eu; \
done; \
rm -rf /root/.cache/pip /sandbox/.cache \
&& chown -R sandbox:sandbox /sandbox/.hermes \
&& chown gateway:sandbox /sandbox/.hermes/runtime \
&& chmod 750 /sandbox/.hermes \
&& chmod 770 \
/sandbox/.hermes/memories \
/sandbox/.hermes/sessions \
/sandbox/.hermes/skills \
/sandbox/.hermes/plugins \
/sandbox/.hermes/cron \
/sandbox/.hermes/logs \
/sandbox/.hermes/skins \
/sandbox/.hermes/plans \
/sandbox/.hermes/workspace \
/sandbox/.hermes/profiles \
/sandbox/.hermes/cache \
/sandbox/.hermes/pairing \
/sandbox/.hermes/runtime \
&& chmod 640 /sandbox/.hermes/config.yaml \
&& chmod 640 /sandbox/.hermes/.env
&& chmod 640 /sandbox/.hermes/.env \
&& for name in state.db state.db-wal state.db-shm gateway.pid gateway.lock gateway_state.json channel_directory.json; do \
rm -f "/sandbox/.hermes/${name}"; \
ln -s "runtime/${name}" "/sandbox/.hermes/${name}"; \
done

# Pin config hash at build time for integrity verification at startup.
RUN mkdir -p /etc/nemoclaw \
Expand Down
76 changes: 60 additions & 16 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# Hermes sandbox base image — expensive, rarely-changing layers.
#
# Contains: node:22-slim (OpenShell needs Node), apt packages, gosu,
# user/group setup, .hermes directory structure, Hermes CLI, and PyYAML.
# user/group setup, .hermes directory structure, Hermes CLI, and the
# dependencies for NemoClaw-supported Hermes integrations.
#
# Mirrors the OpenClaw Dockerfile.base structure but installs Hermes
# (Python-based) instead of OpenClaw (Node-based).
Expand All @@ -22,8 +23,11 @@ FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9
ENV DEBIAN_FRONTEND=noninteractive

# Hermes version pinned for reproducibility.
# Calver tag v2026.4.13 = semver 0.9.0.
ARG HERMES_VERSION=v2026.4.13
# Calver tag v2026.4.23 = semver 0.11.0.
ARG HERMES_VERSION=v2026.4.23
ARG HERMES_TARBALL_SHA256=1ee1be80a2112b7edc581770cee8858e725ba110cc423979cd7102492504bc6b
ARG HERMES_UV_EXTRAS="messaging web"
ARG UV_VERSION=0.11.8

RUN apt-get update && apt-get install -y --no-install-recommends \
python3=3.11.2-1+b1 \
Expand Down Expand Up @@ -54,16 +58,18 @@ RUN arch="$(dpkg --print-architecture)" \

# Create sandbox user (matches OpenShell convention) and gateway user.
# gateway is a member of the sandbox group so it can read Hermes config files
# with group-read permissions while the sandbox user remains the only writer.
# and write Hermes runtime state while config files remain non-group-writable.
RUN groupadd -r sandbox \
&& groupadd -r gateway \
&& useradd -r -g gateway -G sandbox -d /sandbox -s /usr/sbin/nologin gateway \
&& useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \
&& usermod -a -G sandbox root \
&& mkdir -p /sandbox/.nemoclaw \
&& chown -R sandbox:sandbox /sandbox

# Create .hermes with all state subdirs directly (mutable by default).
# No separate .hermes-data or symlink bridge.
# Create .hermes with mutable integration dirs plus a scoped runtime dir.
# Top-level gateway state names redirect into runtime so the config root can
# remain non-group-writable without reintroducing the old .hermes-data bridge.
RUN mkdir -p /sandbox/.hermes/memories \
/sandbox/.hermes/sessions \
/sandbox/.hermes/skills \
Expand All @@ -76,14 +82,52 @@ RUN mkdir -p /sandbox/.hermes/memories \
/sandbox/.hermes/profiles \
/sandbox/.hermes/cache \
/sandbox/.hermes/pairing \
&& chown -R sandbox:sandbox /sandbox/.hermes
/sandbox/.hermes/runtime \
&& chown -R sandbox:sandbox /sandbox/.hermes \
&& chown gateway:sandbox /sandbox/.hermes/runtime \
&& chmod 750 /sandbox/.hermes \
&& chmod 770 \
/sandbox/.hermes/memories \
/sandbox/.hermes/sessions \
/sandbox/.hermes/skills \
/sandbox/.hermes/plugins \
/sandbox/.hermes/cron \
/sandbox/.hermes/logs \
/sandbox/.hermes/skins \
/sandbox/.hermes/plans \
/sandbox/.hermes/workspace \
/sandbox/.hermes/profiles \
/sandbox/.hermes/cache \
/sandbox/.hermes/pairing \
/sandbox/.hermes/runtime \
&& for name in state.db state.db-wal state.db-shm gateway.pid gateway.lock gateway_state.json channel_directory.json; do \
rm -f "/sandbox/.hermes/${name}"; \
ln -s "runtime/${name}" "/sandbox/.hermes/${name}"; \
done

# Install Hermes Agent from the selected GitHub release.
# The image prebakes only the extras mapped to NemoClaw-supported onboarding
# integrations: messaging (Telegram, Discord, Slack) and web (API health/UI
# runtime). New Hermes integrations should be installed by the agent workflow
# when they are enabled rather than shipped in the base image by default.
RUN pip3 install --no-cache-dir --break-system-packages "uv==${UV_VERSION}"
RUN mkdir -p /opt/hermes \
&& curl -fsSL "https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz" -o /tmp/hermes.tar.gz \
&& printf '%s /tmp/hermes.tar.gz\n' "${HERMES_TARBALL_SHA256}" > /tmp/hermes.tar.gz.sha256 \
&& sha256sum -c /tmp/hermes.tar.gz.sha256 \
&& tar -xzf /tmp/hermes.tar.gz -C /opt/hermes --strip-components=1 \
&& rm /tmp/hermes.tar.gz /tmp/hermes.tar.gz.sha256
WORKDIR /opt/hermes
# hadolint ignore=SC2086
RUN set -eu; \
set --; \
for extra in ${HERMES_UV_EXTRAS}; do \
set -- "$@" --extra "$extra"; \
done; \
uv sync --frozen --no-dev "$@" --no-cache \
&& ln -sf /opt/hermes/.venv/bin/hermes /usr/local/bin/hermes \
&& ln -sf /opt/hermes/.venv/bin/hermes-agent /usr/local/bin/hermes-agent \
&& ln -sf /opt/hermes/.venv/bin/hermes-acp /usr/local/bin/hermes-acp

# Install Hermes Agent from GitHub release.
# Hermes is not on PyPI — official install is via their install script or
# direct pip install from the release tarball.
# hadolint ignore=DL3013
RUN pip3 install --no-cache-dir --break-system-packages \
"hermes-agent @ https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz" \
"pyyaml==6.0.3" \
"python-telegram-bot>=21.0" \
&& hermes --version
ENV PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}"
RUN /usr/local/bin/hermes --version
7 changes: 4 additions & 3 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ homepage: "https://github.com/NousResearch/hermes-agent"
install_method: curl # curl install.sh | bash
binary_path: /usr/local/bin/hermes
version_command: "hermes --version"
expected_version: "2026.4.8"
expected_version: "2026.4.23"
gateway_command: "hermes gateway run"

# ── Health probe ────────────────────────────────────────────────
Expand All @@ -33,8 +33,9 @@ health_probe:
# etc.) can connect at http://localhost:8642/v1. Auth is via a bearer
# token in the Authorization header, not a URL fragment.
#
# The optional `hermes dashboard` web UI (pip install hermes-agent[web])
# listens on a different port and is not installed by default.
# NemoClaw prebakes Hermes' `web` extra so the API/health runtime is available.
# The optional `hermes dashboard` web UI listens on a different port and is not
# exposed by default.
dashboard:
kind: api # "ui" or "api"
label: "OpenAI-compatible API"
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ filesystem_policy:
read_only:
- /usr
- /lib
- /opt/hermes
- /proc
- /dev/urandom
- /app
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ filesystem_policy:
read_only:
- /usr
- /lib
- /opt/hermes
- /proc
- /dev/urandom
- /app
Expand Down
65 changes: 54 additions & 11 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ fi
# SECURITY: Lock down PATH
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# ── Early stderr/stdout capture ──────────────────────────────────
# Capture all entrypoint output to /tmp/nemoclaw-start.log so startup
# failures before /tmp/gateway.log exists are still diagnosable.
prepare_restricted_log() {
local path="$1"
local owner="${2:-}"
local mode="${3:-600}"
local dir base tmp

dir="$(dirname "$path")"
base="$(basename "$path")"
tmp="$(mktemp "${dir}/.${base}.tmp.XXXXXX")" || return 1
: >"$tmp" || {
rm -f "$tmp"
return 1
}
if [ "$(id -u)" -eq 0 ] && [ -n "$owner" ] && ! chown "$owner" "$tmp"; then
rm -f "$tmp"
return 1
fi
if ! chmod "$mode" "$tmp"; then
rm -f "$tmp"
return 1
fi
if ! mv -f "$tmp" "$path"; then
rm -f "$tmp"
Comment on lines +66 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, examine the actual file to see the context
head -80 agents/hermes/start.sh | tail -40

Repository: NVIDIA/NemoClaw

Length of output: 1102


🏁 Script executed:

# Check if the file has SPDX headers
head -5 agents/hermes/start.sh

Repository: NVIDIA/NemoClaw

Length of output: 266


🏁 Script executed:

# Get more context around the function in question
sed -n '45,80p' agents/hermes/start.sh

Repository: NVIDIA/NemoClaw

Length of output: 898


🌐 Web query:

GNU coreutils mv -T flag symlink directory behavior

💡 Result:

The GNU coreutils mv command with the -T (or --no-target-directory) flag does not treat the destination operand specially if it is a directory or a symbolic link to a directory. Normally, without -T, if the destination is a directory or symlink to a directory, mv moves the source into that directory (e.g., mv source dest becomes mv source dest/source). With -T, mv treats the destination as a normal file path, attempting to rename or move the source directly to that exact path, even if dest exists as a directory or symlink to one. This enables overwriting or replacing a directory symlink atomically with another file or symlink. This behavior holds regardless of whether the destination is a symlink to a directory; the check for special treatment explicitly includes "a directory or a symbolic link to a directory". The mv source code confirms that when no_target_directory is set, it skips the directory detection logic for the last operand and performs a direct rename operation. Note that symlink handling also depends on trailing slashes (use --strip-trailing-slashes if needed) and the underlying rename(2) syscall behavior, which varies by OS/kernel (e.g., modern Linux fails with ENOTDIR for symlink-to-dir sources with trailing slash, while others may dereference).

Citations:


🏁 Script executed:

# Check for Dockerfile or system requirements to understand target environment
find . -name "Dockerfile*" -o -name "*.dockerfile" | head -5

Repository: NVIDIA/NemoClaw

Length of output: 175


🏁 Script executed:

# Check if other shell scripts use GNU-specific mv flags
rg "mv\s+-[a-zA-Z]*T" --type sh

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Check for any POSIX compatibility notes or system requirements
rg "POSIX|portability|compatibility" README.md 2>/dev/null | head -20

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# See if there are any CI/build configs that indicate target platform
find . -name ".github" -type d -exec find {} -name "*.yml" -o -name "*.yaml" \; | head -5

Repository: NVIDIA/NemoClaw

Length of output: 354


🏁 Script executed:

# Check the Hermes Dockerfile to see base image
cat agents/hermes/Dockerfile | head -20

Repository: NVIDIA/NemoClaw

Length of output: 908


🏁 Script executed:

# Also check the base image
cat agents/hermes/Dockerfile.base | head -20

Repository: NVIDIA/NemoClaw

Length of output: 1042


🏁 Script executed:

# Check where prepare_restricted_log is called (should be 2+ times)
rg "prepare_restricted_log" agents/hermes/start.sh -n

Repository: NVIDIA/NemoClaw

Length of output: 311


🏁 Script executed:

# Verify the second usage for gateway.log
sed -n '500,570p' agents/hermes/start.sh

Repository: NVIDIA/NemoClaw

Length of output: 3064


Use mv -T when publishing the restricted log file.

On line 66, mv -f treats a destination symlink-to-directory as a directory, so a pre-planted /tmp/nemoclaw-start.log or /tmp/gateway.log link can redirect the temp file into an attacker-chosen directory instead of replacing the log path. Because this helper is reused for both restricted logs, the /tmp hardening is incomplete.

🔒 Proposed hardening
-  if ! mv -f "$tmp" "$path"; then
+  if ! mv -fT "$tmp" "$path"; then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@agents/hermes/start.sh` around lines 66 - 67, The mv invocation in
agents/hermes/start.sh currently uses mv -f "$tmp" "$path" which will follow a
destination symlink-to-directory and can be exploited; change the publish step
to use mv -T (atomic replace of destination) so the temp file replaces the
target path rather than being moved into a symlinked directory — update the mv
command that handles the "$tmp" -> "$path" replacement to use mv -T (and keep
the existing -f behavior) and ensure the cleanup branch still removes "$tmp" on
failure.

return 1
fi
}

_START_LOG="/tmp/nemoclaw-start.log"
if [ "$(id -u)" -eq 0 ]; then
prepare_restricted_log "$_START_LOG" root:root 600
else
prepare_restricted_log "$_START_LOG" "" 600
fi
exec > >(tee -a "$_START_LOG") 2> >(tee -a "$_START_LOG" >&2)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# ── Drop unnecessary Linux capabilities (shared) ────────────────
drop_capabilities /usr/local/bin/nemoclaw-start "$@"

Expand Down Expand Up @@ -77,9 +115,10 @@ PUBLIC_PORT=8642
INTERNAL_PORT=18642
HERMES="$(command -v hermes)" # Resolve once, use absolute path everywhere

# Hermes writes state files (PID, state.db, .channel_directory) directly into
# HERMES_HOME alongside config. Config is mutable by default for the sandbox user
# and group-readable by the gateway user. Immutability is opt-in via `shields up`.
# Hermes resolves config and runtime state relative to HERMES_HOME. The config
# root is mutable by the sandbox owner and readable by the gateway group, while
# gateway-created top-level state is redirected to a scoped runtime directory.
# Immutability is opt-in via `shields up`.
HERMES_DIR="/sandbox/.hermes"
HERMES_HASH_FILE="/etc/nemoclaw/hermes.config-hash"

Expand Down Expand Up @@ -186,6 +225,11 @@ print_dashboard_urls() {
echo "[gateway] Connect any OpenAI-compatible frontend to this endpoint." >&2
}

start_gateway_log_stream() {
{ tail -n +1 -F /tmp/gateway.log 2>/dev/null | sed -u 's/^/[gateway-log:] /' >&2; } &
GATEWAY_LOG_TAIL_PID=$!
}

# ── socat forwarder ──────────────────────────────────────────────
# Hermes API server binds to 127.0.0.1 regardless of config (upstream bug).
# OpenShell needs the port accessible on 0.0.0.0 for port forwarding.
Expand Down Expand Up @@ -453,9 +497,7 @@ if [ "$(id -u)" -ne 0 ]; then
exec "${NEMOCLAW_CMD[@]}"
fi

# TODO(#2277-P2): migrate to shared emit_restricted_log() helper
touch /tmp/gateway.log
chmod 600 /tmp/gateway.log
prepare_restricted_log /tmp/gateway.log "" 600

# Defence-in-depth: verify /tmp file permissions before launching services.
# shellcheck disable=SC2119
Expand All @@ -471,11 +513,13 @@ if [ "$(id -u)" -ne 0 ]; then
nohup "$HERMES" gateway run >/tmp/gateway.log 2>&1 &
GATEWAY_PID=$!
echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2
start_gateway_log_stream
# NOTE: PIDs are collected after launch; a signal arriving between trap
# registration and the final append is a small race window (same as before
# the shared-library refactor). Acceptable for entrypoint-level cleanup.
SANDBOX_CHILD_PIDS=("$GATEWAY_PID")
[ -n "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID")
[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")
# shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh
SANDBOX_WAIT_PID="$GATEWAY_PID"
trap cleanup_on_signal SIGTERM SIGINT
Expand All @@ -498,10 +542,7 @@ if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then
fi

# SECURITY: Protect gateway log from sandbox user tampering
# TODO(#2277-P2): migrate to shared emit_restricted_log() helper
touch /tmp/gateway.log
chown gateway:gateway /tmp/gateway.log
chmod 600 /tmp/gateway.log
prepare_restricted_log /tmp/gateway.log gateway:gateway 600

# Defence-in-depth: verify /tmp file permissions before launching services.
# shellcheck disable=SC2119
Expand All @@ -514,14 +555,16 @@ HERMES_HOME="${HERMES_DIR}" \
HTTP_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \
https_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \
http_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \
nohup gosu gateway "$HERMES" gateway run >/tmp/gateway.log 2>&1 &
nohup gosu gateway sh -c 'exec "$@" >/tmp/gateway.log 2>&1' sh "$HERMES" gateway run &
GATEWAY_PID=$!
echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2
start_gateway_log_stream
# NOTE: PIDs are collected after launch; a signal arriving between trap
# registration and the final append is a small race window (same as before
# the shared-library refactor). Acceptable for entrypoint-level cleanup.
SANDBOX_CHILD_PIDS=("$GATEWAY_PID")
[ -n "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID")
[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")
# shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh
SANDBOX_WAIT_PID="$GATEWAY_PID"
trap cleanup_on_signal SIGTERM SIGINT
Expand Down
37 changes: 37 additions & 0 deletions test/e2e/test-hermes-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,42 @@ section() {
}
info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; }

dump_hermes_diagnostics() {
info "--- Hermes sandbox diagnostics ---"
if ! command -v openshell >/dev/null 2>&1; then
info "openshell is not available for sandbox diagnostics"
return
fi

local sandboxes diag_output diag_script
sandboxes=$(openshell sandbox list 2>&1 || true)
info "openshell sandbox list:"
echo "$sandboxes" | tail -20 | while IFS= read -r line; do
info " $line"
done

if ! grep -Fq -- "$SANDBOX_NAME" <<<"$sandboxes"; then
info "sandbox '${SANDBOX_NAME}' is not visible to openshell"
return
fi

diag_script='set +e'
diag_script+='; echo "== identity =="; id 2>&1 || true'
diag_script+='; echo "== listening sockets =="; ss -tlnp 2>&1 || ss -tln 2>&1 || true'
diag_script+='; echo "== log and state paths =="; ls -ld /tmp /sandbox/.hermes /sandbox/.hermes/logs 2>&1 || true; ls -l /tmp/nemoclaw-start.log /tmp/gateway.log 2>&1 || true'
diag_script+='; echo "== hermes-related processes =="'
# shellcheck disable=SC2016 # script is intentionally evaluated inside the sandbox
diag_script+='; for p in /proc/[0-9]*; do cmd=$(tr "\000" " " < "$p/cmdline" 2>/dev/null || true); case "$cmd" in *hermes*|*socat*|*nemoclaw-decode-proxy*) echo "$(basename "$p") $cmd" ;; esac; done'
diag_script+='; echo "== /tmp/nemoclaw-start.log tail =="; tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true'
diag_script+='; echo "== /tmp/gateway.log tail =="; tail -n 120 /tmp/gateway.log 2>&1 || true'
diag_output=$(openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc "$diag_script" 2>&1 || true)

echo "$diag_output" | while IFS= read -r line; do
info " $line"
done
info "--- End Hermes sandbox diagnostics ---"
}

# Parse chat completion response — handles both content and reasoning_content
# (nemotron-3-super is a reasoning model that may put output in reasoning_content)
parse_chat_content() {
Expand Down Expand Up @@ -196,6 +232,7 @@ if [ $install_exit -eq 0 ]; then
pass "install.sh completed (exit 0)"
else
fail "install.sh failed (exit $install_exit)"
dump_hermes_diagnostics
exit 1
fi

Expand Down
Loading
Loading