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
80 changes: 79 additions & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
COPY agents/hermes/security-dependencies.patch /scripts/hermes-security-dependencies.patch

FROM scratch AS hermes-agent-payload

Expand Down Expand Up @@ -166,6 +167,10 @@ FROM hermes-managed-teams-${NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION}-wheels AS h
# hadolint ignore=DL3006
FROM ${BASE_IMAGE}

# Keep the stock-image lazy dependency target when a published base image lags
# Dockerfile.base. The final stage also creates and verifies the directory.
ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages

# Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When
# onboard detects an operator-supplied corporate CA on the host it bakes it
# here; the RUN below decodes it to a root-owned file that the entrypoint
Expand Down Expand Up @@ -207,6 +212,29 @@ ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem
# one final-image layer while preserving metadata on existing parent paths.
COPY --from=hermes-npm-patch-payload / /

# A published base can lag the source patch in Dockerfile.base. Apply only the
# two Hindsight compatibility hunks when needed, then verify the final source
# contract. This remains idempotent once the published base contains them.
RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \
git -C /opt/hermes apply --check \
--include=hermes_cli/memory_setup.py \
/scripts/hermes-security-dependencies.patch; \
git -C /opt/hermes apply \
--include=hermes_cli/memory_setup.py \
/scripts/hermes-security-dependencies.patch; \
fi \
&& if ! grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml; then \
git -C /opt/hermes apply --check \
--include=plugins/memory/hindsight/plugin.yaml \
/scripts/hermes-security-dependencies.patch; \
git -C /opt/hermes apply \
--include=plugins/memory/hindsight/plugin.yaml \
/scripts/hermes-security-dependencies.patch; \
fi \
&& grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py \
&& grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml \
&& rm /scripts/hermes-security-dependencies.patch

# The final Hermes image owns the shipped dependency boundary independently of
# base freshness. Reassert the idempotent npm-private node-tar fix here. When
# onboarding supplied a corporate CA, use it for the registry-backed download.
Expand Down Expand Up @@ -628,7 +656,7 @@ RUN node --experimental-strip-types \
ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e
ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1
ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=a0c87387c0a00e7aad5892375303115577d72293ea6af7d15d1861e6a02c62c6
ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819
ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0
# hadolint ignore=DL4006
Expand Down Expand Up @@ -1034,6 +1062,7 @@ RUN set -eu; \
"$config_dir/weixin" \
"$config_dir/weixin/accounts" \
"$config_dir/runtime" \
"$config_dir/lazy-packages" \
"$config_dir/profiles/dashboard-home"; \
if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then \
echo "ERROR: legacy data dir still exists after cleanup: $data_dir" >&2; \
Expand Down Expand Up @@ -1088,6 +1117,7 @@ RUN set -eu; \
/sandbox/.hermes/weixin/accounts \
/sandbox/.hermes/runtime \
/sandbox/.hermes/profiles/dashboard-home \
&& chmod 750 /sandbox/.hermes/lazy-packages \
&& chmod 2770 \
/sandbox/.hermes/logs \
/sandbox/.hermes/logs/curator \
Expand All @@ -1109,6 +1139,52 @@ RUN set -eu; \
&& chown sandbox:sandbox /sandbox/.hermes/.hermes_history \
&& chmod 660 /sandbox/.hermes/.hermes_history

# Exercise the real reviewed lazy installer under the sandbox identity, then
# perform the same read-only state-guard transition used by Shields Up. The
# gateway must retain import access after the lock while both runtime identities
# remain unable to mutate the dependency tree. Remove the probe install so the
# published image still performs installation only when Hindsight is selected.
RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" \
&& HOME=/sandbox \
UV_CACHE_DIR=/sandbox/.hermes/cache/uv \
UV_NO_CACHE=1 \
HERMES_HOME=/sandbox/.hermes \
HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages \
/usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \
/opt/hermes/.venv/bin/python -I -c \
"from tools.lazy_deps import ensure; ensure('memory.hindsight', prompt=False)" \
&& HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages \
/usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \
/opt/hermes/.venv/bin/python -I -c \
"from tools.lazy_deps import activate_durable_lazy_target; activate_durable_lazy_target(); import importlib.metadata as m; assert m.version('hindsight-client') == '0.6.1'" \
&& lazy_plan='{"version":1,"readOnlyRoots":["lazy-packages"],"confidentialRoots":[],"readOnlyPrefixes":[],"confidentialPrefixes":[],"writableSubpaths":[]}' \
&& /usr/local/lib/nemoclaw/state-dir-guard.py lock \
--config-dir /sandbox/.hermes --plan-json "$lazy_plan" \
&& /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- \
env HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages \
/opt/hermes/.venv/bin/python -I -c \
"from tools.lazy_deps import activate_durable_lazy_target; activate_durable_lazy_target(); import hindsight_client" \
&& if /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- \
sh -c ': > /sandbox/.hermes/lazy-packages/.nemoclaw-gateway-write-probe' 2>/dev/null; then \
echo "ERROR: gateway can modify locked Hermes lazy packages" >&2; exit 1; \
fi \
&& if /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \
sh -c ': > /sandbox/.hermes/lazy-packages/.python-abi' 2>/dev/null; then \
echo "ERROR: sandbox can modify locked Hermes lazy packages" >&2; exit 1; \
fi \
&& test ! -e /sandbox/.hermes/lazy-packages/.nemoclaw-gateway-write-probe \
&& /usr/local/lib/nemoclaw/state-dir-guard.py unlock \
--config-dir /sandbox/.hermes --plan-json "$lazy_plan" \
&& /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \
sh -c ': > /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe' \
&& test -f /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe \
&& rm -rf /sandbox/.hermes/lazy-packages \
&& install -d -o sandbox -g sandbox -m 0750 /sandbox/.hermes/lazy-packages \
&& test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" \
&& chown sandbox:sandbox /sandbox/.hermes \
&& chmod 3770 /sandbox/.hermes \
&& test "$(stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770"

# Prove the cron execution ledger contract across the real image identities.
# Hermes' gateway-side scheduler creates the live WAL-backed database in the
# writable runtime boundary, sandbox reads and online-copies it before
Expand Down Expand Up @@ -1197,6 +1273,8 @@ RUN chown root:root /sandbox/.nemoclaw \
RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]; then \
chmod -R a+rwX /sandbox/.hermes; \
find /sandbox/.hermes -type d -exec chmod a+rwx {} +; \
find /sandbox/.hermes/lazy-packages -type d -exec chmod 750 {} +; \
find /sandbox/.hermes/lazy-packages -type f -exec chmod 640 {} +; \
for p in /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging; do \
chmod a+rwx "$p"; \
done; \
Expand Down
6 changes: 6 additions & 0 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ RUN mkdir -p /sandbox/.hermes/memories \
/sandbox/.hermes/platforms/whatsapp/session \
/sandbox/.hermes/gateway \
/sandbox/.hermes/runtime \
/sandbox/.hermes/lazy-packages \
&& chown -R sandbox:sandbox /sandbox/.hermes \
&& chown gateway:sandbox \
/sandbox/.hermes/cron \
Expand Down Expand Up @@ -352,6 +353,7 @@ RUN mkdir -p /sandbox/.hermes/memories \
/sandbox/.hermes/platforms/whatsapp/session \
/sandbox/.hermes/gateway \
/sandbox/.hermes/runtime \
&& chmod 750 /sandbox/.hermes/lazy-packages \
&& chmod 2770 \
/sandbox/.hermes/logs \
/sandbox/.hermes/logs/curator \
Expand All @@ -369,6 +371,10 @@ RUN mkdir -p /sandbox/.hermes/memories \
&& chown sandbox:sandbox /sandbox/.hermes/.hermes_history \
&& chmod 660 /sandbox/.hermes/.hermes_history

# Hermes installs opt-in dependencies here instead of the sealed root venv.
# Hermes appends this directory after its trusted site-packages at runtime.
ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages

# Pre-create shell init files for the sandbox user.
# The Hermes entrypoint writes proxy vars and
# HERMES_HOME to /tmp/nemoclaw-proxy-env.sh (mode 444, root-owned when the
Expand Down
3 changes: 3 additions & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ config:
state_lock_plan_in_image: true
state_dirs:
- memories
# Hermes lazy dependency installs persist here outside sealed configuration.
- path: lazy-packages
shields: read-only
- sessions
- path: skills
shields: read-only
Expand Down
35 changes: 35 additions & 0 deletions agents/hermes/security-dependencies.patch
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ index c630b3c..fd28f6a 100644
all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py
index f6345d2..5bf65a4 100644
--- a/hermes_cli/memory_setup.py
+++ b/hermes_cli/memory_setup.py
@@ -123,1 +123,17 @@ def _install_dependencies(provider_name: str) -> None:
+ # NemoClaw seals the Hermes venv and redirects this reviewed dependency
+ # to a durable sandbox-owned target. Reuse the allowlisted lazy installer
+ # so setup never falls back to mutating /opt/hermes or resolving a looser
+ # plugin.yaml range. The setup command itself runs as the sandbox user.
+ if provider_name == "hindsight":
+ from tools.lazy_deps import FeatureUnavailable, ensure
+
+ try:
+ ensure("memory.hindsight", prompt=False)
+ except FeatureUnavailable as exc:
+ raise RuntimeError(
+ "Hindsight dependency installation failed through the managed lazy target"
+ ) from exc
+ print(" Installed hindsight-client==0.6.1")
+ return
+
print(f"\n Installing dependencies: {', '.join(missing)}")
diff --git a/plugins/memory/hindsight/plugin.yaml b/plugins/memory/hindsight/plugin.yaml
index 5b37014..f763fd5 100644
--- a/plugins/memory/hindsight/plugin.yaml
+++ b/plugins/memory/hindsight/plugin.yaml
@@ -1,7 +1,7 @@
name: hindsight
version: 1.0.0
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
pip_dependencies:
- - "hindsight-client>=0.6.1"
+ - "hindsight-client==0.6.1"
requires_env: []
hooks:
diff --git a/uv.lock b/uv.lock
index 257f7d6..d2f7607 100644
--- a/uv.lock
Expand Down
54 changes: 53 additions & 1 deletion agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,54 @@ verify_hermes_config_integrity() {
fi
}

prepare_hermes_lazy_dependencies() {
local -a installer=(
env
HOME=/sandbox
UV_CACHE_DIR=/sandbox/.hermes/cache/uv
UV_NO_CACHE=1
HERMES_HOME="$HERMES_DIR"
HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages
)

# The separated gateway identity deliberately has no write access to the
# durable dependency tree. Route the allowlisted installer through sandbox;
# same-UID OpenShell startup is already running under that identity.
if [ "$(id -u)" -eq 0 ]; then
installer+=("${STEP_DOWN_PREFIX_SANDBOX[@]}")
fi
installer+=("$_HERMES_PYTHON" -I -c)

"${installer[@]}" '
import os
from pathlib import Path

import yaml

config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception as exc:
raise SystemExit(f"[SECURITY] Unable to inspect Hermes memory configuration: {exc}") from exc

memory = config.get("memory") if isinstance(config, dict) else None
provider = memory.get("provider") if isinstance(memory, dict) else None
if provider != "hindsight":
raise SystemExit(0)

from tools.lazy_deps import activate_durable_lazy_target, ensure

activate_durable_lazy_target()
try:
ensure("memory.hindsight", prompt=False)
except Exception as exc:
raise SystemExit(
"[SECURITY] Unable to prepare the approved Hindsight dependency "
f"under the sandbox-owned lazy-install target: {exc}"
) from exc
'
}

# configure_messaging_channels is provided by sandbox-init.sh (shared).

print_dashboard_urls() {
Expand Down Expand Up @@ -1920,6 +1968,7 @@ export http_proxy="$_PROXY_URL"
export https_proxy="$_PROXY_URL"
export no_proxy="$_NO_PROXY_VAL"
export HERMES_HOME="${HERMES_DIR}"
export HERMES_LAZY_INSTALL_TARGET="/sandbox/.hermes/lazy-packages"
PROXYEOF
cat <<'TUIENVEOF'
if [ -f /opt/hermes/ui-tui/dist/entry.js ]; then
Expand Down Expand Up @@ -2332,7 +2381,8 @@ prepare_hermes_gateway_restart() {
# sandboxes instead of chowning attacker-controlled paths or adopting a new
# hash here.
HERMES_RESTART_FAILURE_CODE=hash-mismatch
verify_hermes_config_integrity
verify_hermes_config_integrity || return 1
prepare_hermes_lazy_dependencies
}

hermes_restart_unseal_on_exit() {
Expand Down Expand Up @@ -3028,6 +3078,7 @@ prepare_hermes_nonroot_runtime() {
# startup mutations below so their outputs remain covered as well.
validate_hermes_env_secret_boundary || return 1
inspect_hermes_mcp_integrity "${HERMES_DIR}/.config-hash" || return 1
prepare_hermes_lazy_dependencies || return 1
ensure_hermes_runtime_api_server_key compat || return 1
apply_shields_up_runtime_env || return 1
validate_hermes_env_secret_boundary || return 1
Expand All @@ -3041,6 +3092,7 @@ prepare_hermes_nonroot_runtime() {

prepare_hermes_root_runtime() {
verify_hermes_config_integrity || return 1
prepare_hermes_lazy_dependencies || return 1
ensure_hermes_config_root_mode || return 1
ensure_hermes_runtime_api_server_key both || return 1
apply_shields_up_runtime_env || return 1
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/state-lock-plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"readOnlyRoots": [
"cron",
"hooks",
"lazy-packages",
"platforms",
"plugins",
"profiles",
Expand Down
18 changes: 16 additions & 2 deletions agents/hermes/validate-env-secret-boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,11 @@ def validate_env_file(path: str) -> int:
if len(violations) < MAX_VIOLATIONS:
violations.append(f"{key} (line {lineno})")
continue
if key == "HERMES_LAZY_INSTALL_TARGET":
violation_count += 1
if len(violations) < MAX_VIOLATIONS:
violations.append(f"{key} (line {lineno})")
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if key in ENV_FILE_ALLOWED_NONSECRET_KEYS:
continue
if key in ENV_FILE_ALLOWED_RAW_SECRET_KEYS and is_allowed_raw_secret_value(
Expand All @@ -481,7 +486,9 @@ def validate_env_file(path: str) -> int:
_emit_violations(
"[SECURITY] Refusing Hermes startup because /sandbox/.hermes/.env "
"contains raw secret-shaped values or OpenShell supervisor-only identity "
"variables. Store credentials in OpenShell providers and keep only "
"variables, or declares HERMES_LAZY_INSTALL_TARGET. Store credentials "
"in OpenShell providers, keep the managed lazy-install target outside "
"the sealed env file, and keep only "
"openshell resolver placeholders in the sandbox.",
violations,
violation_count - len(violations),
Expand All @@ -493,12 +500,18 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int:
source = os.environ if env is None else env
violations: list[str] = []
violation_count = 0
if source.get("HERMES_LAZY_INSTALL_TARGET") != "/sandbox/.hermes/lazy-packages":
violation_count += 1
if len(violations) < MAX_VIOLATIONS:
violations.append("HERMES_LAZY_INSTALL_TARGET")
for key, value in sorted(source.items()):
if key in OPENSHELL_SUPERVISOR_ONLY_ENV_KEYS:
violation_count += 1
if len(violations) < MAX_VIOLATIONS:
violations.append(key)
continue
if key == "HERMES_LAZY_INSTALL_TARGET":
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if key in RUNTIME_ALLOWED_NONSECRET_KEYS:
continue
if key in RUNTIME_ALLOWED_RAW_SECRET_KEYS and is_allowed_raw_secret_value(
Expand All @@ -519,7 +532,8 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int:
_emit_violations(
"[SECURITY] Refusing Hermes startup because the process environment "
"contains raw secret-shaped values or OpenShell supervisor-only identity "
"variables. Store credentials in OpenShell providers and keep only "
"variables, or does not use the managed HERMES_LAZY_INSTALL_TARGET. "
"Store credentials in OpenShell providers and keep only "
"openshell resolver placeholders in the sandbox.",
violations,
violation_count - len(violations),
Expand Down
Loading
Loading