From 89d380261d1b5ebd69c3b87504da2f89fb0666f5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:41:15 -0700 Subject: [PATCH] fix(approval): resolve Hermes home at detection time, not import time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tools/approval.py | 71 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/tools/approval.py b/tools/approval.py index 92bbe592131..2fba7e1101b 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -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 ``/`` 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