From 1f5219fda5fc862c558c5dcd8cbb22453d2508ea Mon Sep 17 00:00:00 2001 From: Pratik Rai Date: Thu, 23 Apr 2026 02:29:22 +0530 Subject: [PATCH] fix(security): protect Hermes control-plane files from prompt injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds active-HERMES_HOME control-plane files to the write deny list: auth.json, config.yaml, webhook_subscriptions.json, and any path under mcp-tokens/. realpath() resolves before comparison so directory-traversal and symlink targets are normalised, preventing trivial deny-list bypass via ../ tricks. Without this, a prompt-injected agent could rewrite Hermes' own auth state or routing config via write_file / patch — without triggering the terminal dangerous-command approval — and persist attacker-controlled behaviour across sessions. Fixes #14072 --- agent/file_safety.py | 23 +++++++++++++++ tests/tools/test_file_operations.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/agent/file_safety.py b/agent/file_safety.py index f8678b68c06..0c6e4cc8da6 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -97,6 +97,29 @@ def is_write_denied(path: str) -> bool: if resolved.startswith(prefix): return True + # New: Check for Hermes control files and mcp-tokens directory + hermes_home = _hermes_home_path() + hermes_home_real = os.path.realpath(hermes_home) + + # Check for exact control files + hermes_control_files = [ + os.path.join(hermes_home_real, "auth.json"), + os.path.join(hermes_home_real, "config.yaml"), + os.path.join(hermes_home_real, "webhook_subscriptions.json"), + ] + for control_file in hermes_control_files: + if resolved == os.path.realpath(control_file): + return True + + # Check for anything inside mcp-tokens directory + mcp_tokens_dir = os.path.join(hermes_home_real, "mcp-tokens") + try: + mcp_tokens_dir_real = os.path.realpath(mcp_tokens_dir) + if resolved.startswith(mcp_tokens_dir_real + os.sep): + return True + except Exception: + pass + safe_root = get_safe_write_root() if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)): return True diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index 1fe116ecfa2..266921ebfab 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -60,6 +60,50 @@ class TestIsWriteDenied: def test_tilde_expansion(self): assert _is_write_denied("~/.ssh/authorized_keys") is True + @pytest.mark.parametrize( + "path", + [ + "auth.json", + "config.yaml", + "webhook_subscriptions.json", + "mcp-tokens/token1.json", + "mcp-tokens/subdir/token2.json", + ], + ) + def test_hermes_control_files_and_mcp_tokens_denied(self, path): + """Hermes control files and mcp-tokens entries must be write-denied.""" + from hermes_constants import get_hermes_home + hermes_home = get_hermes_home() + full_path = str(hermes_home / path) + assert _is_write_denied(full_path) is True + + @pytest.mark.parametrize( + "path", + [ + "dummy/../config.yaml", + "./auth.json", + "mcp-tokens/../config.yaml", + ], + ) + def test_hermes_control_files_traversal_denied(self, path): + """Path traversal attempts to control files must be blocked by realpath.""" + from hermes_constants import get_hermes_home + hermes_home = get_hermes_home() + full_path = str(hermes_home / path) + assert _is_write_denied(full_path) is True + + @pytest.mark.parametrize( + "path", + [ + "/tmp/standard_file.txt", + "~/projects/myapp/main.py", + "/var/log/app.log", + ], + ) + def test_standard_paths_allowed(self, path): + """Unrelated paths must still be allowed.""" + assert _is_write_denied(path) is False + # =========================================================================