fix(security): protect Hermes control-plane files from prompt injection
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
This commit is contained in:
@@ -97,6 +97,29 @@ def is_write_denied(path: str) -> bool:
|
|||||||
if resolved.startswith(prefix):
|
if resolved.startswith(prefix):
|
||||||
return True
|
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()
|
safe_root = get_safe_write_root()
|
||||||
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
|
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -60,6 +60,50 @@ class TestIsWriteDenied:
|
|||||||
def test_tilde_expansion(self):
|
def test_tilde_expansion(self):
|
||||||
assert _is_write_denied("~/.ssh/authorized_keys") is True
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user