fix(approval): resolve Hermes home at detection time, not import time

helix4u's fix snapshotted the resolved HERMES_HOME into the static
config/env patterns at module-import time. That breaks when HERMES_HOME
is set after tools.approval is imported (the hermetic test conftest, any
deferred-profile-resolution path), and made the PR's own 4 new tests red.

Move the resolution into _normalize_command_for_detection(): rewrite the
live resolved absolute home prefix (and its symlink-resolved form) to the
canonical ~/.hermes/ form before pattern matching. Tracks the live env,
needs no regex recompile, and folds the absolute form into the shared
_SENSITIVE_WRITE_TARGET so > redirects, tee, cp, etc. are covered too —
not just sed/perl/ruby in-place edits.
This commit is contained in:
teknium1 2026-06-08 09:41:15 -07:00 committed by Teknium
parent b0efe1d64b
commit 89d380261d

View file

@ -152,30 +152,15 @@ def _is_gateway_approval_context() -> bool:
# Sensitive write targets that should trigger approval even when referenced
# via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute
# active profile home path such as /home/hermes/.hermes/config.yaml.
# active profile home path such as /home/hermes/.hermes/config.yaml. The
# resolved-absolute form is folded into the ~/.hermes/ patterns at detection
# time by _normalize_command_for_detection() — see the rewrite step there — so
# these static patterns stay free of any import-time path snapshot (which would
# go stale when HERMES_HOME is set after this module is imported, e.g. under the
# hermetic test conftest or any deferred-profile-resolution path).
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
def _resolved_hermes_home_path_pattern() -> str:
try:
from hermes_constants import get_hermes_home
home = get_hermes_home().expanduser()
candidates = [
str(home).rstrip("/"),
str(home.resolve(strict=False)).rstrip("/"),
]
except Exception:
candidates = []
escaped = [re.escape(path) for path in dict.fromkeys(candidates) if path]
if not escaped:
return r"(?!)"
return r"(?:" + "|".join(escaped) + r")/"
_RESOLVED_HERMES_HOME_PATH = _resolved_hermes_home_path_pattern()
_HERMES_ENV_PATH = (
r'(?:~\/\.hermes/|'
rf'{_RESOLVED_HERMES_HOME_PATH}|'
r'(?:\$home|\$\{home\})/\.hermes/|'
r'(?:\$hermes_home|\$\{hermes_home\})/)'
r'\.env\b'
@ -190,7 +175,6 @@ _HERMES_ENV_PATH = (
# well as ~/.hermes/.
_HERMES_CONFIG_PATH = (
r'(?:~\/\.hermes/|'
rf'{_RESOLVED_HERMES_HOME_PATH}|'
r'(?:\$home|\$\{home\})/\.hermes/|'
r'(?:\$hermes_home|\$\{hermes_home\})/)'
r'config\.yaml\b'
@ -561,8 +545,49 @@ def _normalize_command_for_detection(command: str) -> str:
command = unicodedata.normalize('NFKC', command)
# Strip shell backslash-escapes: r\m → rm. Prevents \-injection bypass.
command = re.sub(r'\\([^\n])', r'\1', command)
# Strip empty-string literals that split tokens: r''m → rm, r""m → rm.
# Strip empty-string literals that split tokens: r''m → rm, r"\"m → rm.
command = re.sub(r"''|\"\"", '', command)
# Fold the resolved absolute active-profile home path into the canonical
# ~/.hermes/ form so the Hermes config/env patterns catch it. In Docker and
# gateway deployments the agent often references the resolved absolute path
# directly (e.g. `sed -i ... /home/hermes/.hermes/config.yaml`) rather than
# ~, $HOME, or $HERMES_HOME. Done at detection time (not via an import-time
# pattern snapshot) so it tracks the live HERMES_HOME even when that is set
# after this module is imported — as the hermetic test conftest does.
command = _rewrite_resolved_hermes_home(command)
return command
def _rewrite_resolved_hermes_home(command: str) -> str:
"""Rewrite the resolved absolute Hermes home prefix to ``~/.hermes/``.
Resolves the active ``HERMES_HOME`` at call time (and its symlink-resolved
form) and replaces an occurrence of ``<home>/`` in *command* with
``~/.hermes/`` so the static ``_HERMES_CONFIG_PATH`` / ``_HERMES_ENV_PATH``
patterns match. No-op when the path can't be resolved or doesn't appear.
"""
try:
from hermes_constants import get_hermes_home
home = get_hermes_home().expanduser()
candidates = [
str(home).rstrip("/"),
str(home.resolve(strict=False)).rstrip("/"),
]
except Exception:
return command
seen: set[str] = set()
for path in candidates:
if not path or path in seen:
continue
seen.add(path)
# Guard against a degenerate HERMES_HOME (e.g. "/" or "") rewriting
# unrelated paths: require an absolute path with at least one non-root
# component. The active profile home is always a real directory like
# /home/hermes/.hermes or a per-test tempdir, never a bare root.
normalized = path.rstrip("/")
if not normalized.startswith("/") or normalized.count("/") < 2:
continue
command = command.replace(normalized + "/", "~/.hermes/")
return command