Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 6 additions & 2 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/
# Ensure sandbox user can read blueprint files copied as root
RUN chmod -R a+rX /opt/nemoclaw-blueprint/

# Copy startup script
# Copy startup script and the secret-boundary validator. The validator is the
# single source of truth shared between start.sh (cold start) and the host-side
# gateway recovery script (src/lib/agent/runtime.ts) so `sandbox recover` enforces
# the same boundary as the initial entrypoint.
COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh
COPY agents/hermes/start.sh /usr/local/bin/nemoclaw-start
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh
COPY agents/hermes/validate-env-secret-boundary.py /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py

# Build args for config that varies per deployment.
ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b
Expand Down
108 changes: 13 additions & 95 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ HERMES="$(command -v hermes)" # Resolve once, use absolute path everywhere
HERMES_DIR="/sandbox/.hermes"
HERMES_HASH_FILE="/etc/nemoclaw/hermes.config-hash"

# Resolve the standalone secret-boundary validator. The container ships it at
# the installed path; the dev fallback resolves against the script directory so
# ad-hoc bash invocations from a checkout work without copying the file. The
# path is set unconditionally so a caller-supplied _HERMES_BOUNDARY_VALIDATOR
# carried in via the entrypoint env wrapper cannot redirect this security check
# at an attacker-controlled script.
_HERMES_BOUNDARY_VALIDATOR="/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py"
if [ ! -f "$_HERMES_BOUNDARY_VALIDATOR" ]; then
_HERMES_BOUNDARY_VALIDATOR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/validate-env-secret-boundary.py"
fi

truthy_env() {
case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
1 | true | yes | on) return 0 ;;
Expand Down Expand Up @@ -1055,104 +1066,11 @@ validate_hermes_env_secret_boundary() {
return 1
fi
[ -f "$env_file" ] || return 0

python3 - "$env_file" <<'PYSECRETBOUNDARY'
import re
import sys

env_file = sys.argv[1]
secret_key_re = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)")
placeholder_re = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$")
allowed_nonsecret_keys = {"API_SERVER_HOST", "API_SERVER_PORT"}
allowed_literals = {"", "[STRIPPED_BY_MIGRATION]"}
violations = []


def unquote(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value


with open(env_file, encoding="utf-8") as fh:
for lineno, raw_line in enumerate(fh, 1):
stripped = raw_line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
if stripped.startswith("export "):
stripped = stripped[len("export ") :].lstrip()
key, value = stripped.split("=", 1)
key = key.strip()
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
continue
if key in allowed_nonsecret_keys:
continue
if not secret_key_re.search(key):
continue
value = unquote(value)
if value in allowed_literals:
continue
if value.startswith("openshell:resolve:env:") or placeholder_re.fullmatch(value):
continue
violations.append(f"{key} (line {lineno})")

if violations:
print(
"[SECURITY] Refusing Hermes startup because /sandbox/.hermes/.env "
"contains raw secret-shaped values. Store credentials in OpenShell "
"providers and keep only openshell resolver placeholders in the sandbox.",
file=sys.stderr,
)
for item in violations:
print(f"[SECURITY] {item}", file=sys.stderr)
sys.exit(1)
PYSECRETBOUNDARY
python3 "$_HERMES_BOUNDARY_VALIDATOR" env-file "$env_file"
}

validate_hermes_runtime_env_secret_boundary() {
python3 - <<'PYRUNTIMESECRETBOUNDARY'
import os
import re
import sys

secret_key_re = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)")
placeholder_re = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$")
allowed_literals = {"", "[STRIPPED_BY_MIGRATION]"}
allowed_raw_secret_keys = {"OPENCLAW_GATEWAY_TOKEN"}
allowed_nonsecret_keys = set((
"API_SERVER_HOST",
"API_SERVER_PORT",
"GPG_KEY",
"NEMOCLAW_INFERENCE_API",
"NEMOCLAW_PROVIDER_KEY",
))
violations = []

for key, value in sorted(os.environ.items()):
if key in allowed_raw_secret_keys or key in allowed_nonsecret_keys:
continue
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
continue
if not secret_key_re.search(key):
continue
if value in allowed_literals:
continue
if value.startswith("openshell:resolve:env:") or placeholder_re.fullmatch(value):
continue
violations.append(key)

if violations:
print(
"[SECURITY] Refusing Hermes startup because the process environment "
"contains raw secret-shaped values. Store credentials in OpenShell "
"providers and keep only openshell resolver placeholders in the sandbox.",
file=sys.stderr,
)
for item in violations:
print(f"[SECURITY] {item}", file=sys.stderr)
sys.exit(1)
PYRUNTIMESECRETBOUNDARY
python3 "$_HERMES_BOUNDARY_VALIDATOR" runtime-env
}

# ── Main ─────────────────────────────────────────────────────────
Expand Down
177 changes: 177 additions & 0 deletions agents/hermes/validate-env-secret-boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Validate the Hermes secret boundary on a .env file or the current process environment.

This is the single source of truth for the documented Hermes secret-boundary
contract. ``start.sh`` invokes the ``env-file`` and ``runtime-env`` subcommands at
sandbox startup, and the host-side gateway recovery path
(``src/lib/agent/runtime.ts:buildRecoveryScript``) invokes the same script before
relaunching the Hermes gateway so the boundary survives ``sandbox recover`` /
``connect --probe-only``.

Exits 0 when the input passes the boundary, 1 when raw secret-shaped values are
present (emitting ``[SECURITY]`` lines on stderr that match the rest of the
gateway startup error contract).
"""

from __future__ import annotations

import argparse
import errno
import os
import re
import stat
import sys
from typing import Iterable

SECRET_KEY_RE = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)")
PLACEHOLDER_RE = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$")
KEY_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")

ENV_FILE_ALLOWED_NONSECRET_KEYS = frozenset({"API_SERVER_HOST", "API_SERVER_PORT"})
RUNTIME_ALLOWED_NONSECRET_KEYS = frozenset(
{
"API_SERVER_HOST",
"API_SERVER_PORT",
"GPG_KEY",
"NEMOCLAW_INFERENCE_API",
"NEMOCLAW_PROVIDER_KEY",
}
)
RUNTIME_ALLOWED_RAW_SECRET_KEYS = frozenset({"OPENCLAW_GATEWAY_TOKEN"})
ALLOWED_LITERALS = frozenset({"", "[STRIPPED_BY_MIGRATION]"})


def unquote(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value


def is_allowed_value(value: str) -> bool:
if value in ALLOWED_LITERALS:
return True
if value.startswith("openshell:resolve:env:"):
return True
if PLACEHOLDER_RE.fullmatch(value):
return True
return False


def _emit_violations(prefix: str, violations: Iterable[str]) -> None:
print(prefix, file=sys.stderr)
for item in violations:
print(f"[SECURITY] {item}", file=sys.stderr)


def validate_env_file(path: str) -> int:
# Open the file with O_NOFOLLOW so a symlink swapped in between any earlier
# lstat check and this read cannot redirect validation to an attacker-chosen
# target. fstat then confirms the descriptor still points at a regular file
# before we parse it, closing the static islink + open(path) TOCTOU gap.
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
try:
fd = os.open(path, flags)
except FileNotFoundError:
return 0
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.EMLINK):
print(
f"[SECURITY] Refusing Hermes startup because {path} is a symlink",
file=sys.stderr,
)
return 1
raise
violations: list[str] = []
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
print(
f"[SECURITY] Refusing Hermes startup because {path} is not a regular file",
file=sys.stderr,
)
return 1
fh = os.fdopen(fd, encoding="utf-8")
fd = -1
with fh:
for lineno, raw_line in enumerate(fh, 1):
stripped = raw_line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
if stripped.startswith("export "):
stripped = stripped[len("export ") :].lstrip()
key, value = stripped.split("=", 1)
key = key.strip()
if not KEY_NAME_RE.fullmatch(key):
continue
if key in ENV_FILE_ALLOWED_NONSECRET_KEYS:
continue
if not SECRET_KEY_RE.search(key):
continue
if is_allowed_value(unquote(value)):
continue
violations.append(f"{key} (line {lineno})")
finally:
if fd != -1:
try:
os.close(fd)
except OSError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
pass
if not violations:
return 0
_emit_violations(
"[SECURITY] Refusing Hermes startup because /sandbox/.hermes/.env "
"contains raw secret-shaped values. Store credentials in OpenShell "
"providers and keep only openshell resolver placeholders in the sandbox.",
violations,
)
return 1


def validate_runtime_env(env: dict[str, str] | None = None) -> int:
source = os.environ if env is None else env
violations: list[str] = []
for key, value in sorted(source.items()):
if key in RUNTIME_ALLOWED_RAW_SECRET_KEYS or key in RUNTIME_ALLOWED_NONSECRET_KEYS:
continue
if not KEY_NAME_RE.fullmatch(key):
continue
if not SECRET_KEY_RE.search(key):
continue
if is_allowed_value(value):
continue
violations.append(key)
if not violations:
return 0
_emit_violations(
"[SECURITY] Refusing Hermes startup because the process environment "
"contains raw secret-shaped values. Store credentials in OpenShell "
"providers and keep only openshell resolver placeholders in the sandbox.",
violations,
)
return 1


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="validate-env-secret-boundary")
sub = parser.add_subparsers(dest="mode", required=True)
env_file_parser = sub.add_parser(
"env-file",
help="Validate a Hermes .env file at the given path",
)
env_file_parser.add_argument("path", help="Path to the .env file to validate")
sub.add_parser(
"runtime-env",
help="Validate the current process environment",
)
args = parser.parse_args(argv)
if args.mode == "env-file":
return validate_env_file(args.path)
assert args.mode == "runtime-env", f"unreachable: argparse subparsers are required ({args.mode!r})"
return validate_runtime_env()


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Loading
Loading