mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-05 07:41:39 +00:00
fix(file-safety): also write-deny <root>/control-files in profile mode
PR #14157 added control-plane write-deny against the ACTIVE HERMES_HOME, which is fine in non-profile mode but leaves a gap once a profile is active: HERMES_HOME points at <root>/profiles/<name>, so the global <root>/auth.json + <root>/config.yaml + <root>/webhook_subscriptions.json + <root>/mcp-tokens/ remain writable. Same shape as the .env gap PR #15981 closed via _hermes_root_path(). Apply the same widening pattern here. The control-file/mcp-tokens check now iterates BOTH _hermes_home_path() and _hermes_root_path() (dedupes when they coincide in non-profile mode). Also tightens the mcp-tokens check from "startswith dir + os.sep" to "==dir OR startswith dir + os.sep" so writing the directory entry itself is blocked, not just files inside. Regression tests cover both protections in a real profile-mode layout (<tmp>/hermes/profiles/coder as HERMES_HOME, <tmp>/hermes as root).
This commit is contained in:
parent
1f5219fda5
commit
42104218e0
2 changed files with 64 additions and 20 deletions
|
|
@ -97,28 +97,36 @@ 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)
|
||||
# Hermes control-plane files: block both the ACTIVE profile's view
|
||||
# (hermes_home) AND the global root view. Without the root pass, a
|
||||
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
|
||||
# writable — letting a prompt-injected write_file overwrite the global
|
||||
# files that every profile inherits from (same shape as #15981).
|
||||
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
# 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
|
||||
hermes_dirs = []
|
||||
for base in (_hermes_home_path(), _hermes_root_path()):
|
||||
try:
|
||||
real = os.path.realpath(base)
|
||||
if real not in hermes_dirs:
|
||||
hermes_dirs.append(real)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 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
|
||||
for base_real in hermes_dirs:
|
||||
for name in control_file_names:
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, name)):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
|
||||
if resolved == mcp_real or resolved.startswith(mcp_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)):
|
||||
|
|
|
|||
|
|
@ -104,6 +104,42 @@ class TestIsWriteDenied:
|
|||
"""Unrelated paths must still be allowed."""
|
||||
assert _is_write_denied(path) is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["auth.json", "config.yaml", "webhook_subscriptions.json"],
|
||||
)
|
||||
def test_control_files_protected_in_profile_mode(self, tmp_path, monkeypatch, name):
|
||||
"""Under a profile, BOTH <profile>/X and <root>/X must be denied (#15981 shape).
|
||||
|
||||
Without the root-level pass, a profile-mode session leaves the
|
||||
global ~/.hermes/{auth.json,config.yaml,webhook_subscriptions.json}
|
||||
writable — the same gap PR #15981 fixed for .env.
|
||||
"""
|
||||
# Simulate a profile-mode HERMES_HOME layout:
|
||||
# <root>/profiles/coder/{auth.json,config.yaml,...}
|
||||
# <root>/{auth.json,config.yaml,...} ← must also be denied
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "coder"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
|
||||
# Profile copy
|
||||
assert _is_write_denied(str(profile / name)) is True
|
||||
# Root copy — the gap this widening closes
|
||||
assert _is_write_denied(str(root / name)) is True
|
||||
|
||||
def test_mcp_tokens_dir_protected_in_profile_mode(self, tmp_path, monkeypatch):
|
||||
"""mcp-tokens/ under profile AND under root must both be denied."""
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "coder"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
|
||||
assert _is_write_denied(str(profile / "mcp-tokens" / "tok.json")) is True
|
||||
assert _is_write_denied(str(root / "mcp-tokens" / "tok.json")) is True
|
||||
# The directory itself must also be denied (not just files inside)
|
||||
assert _is_write_denied(str(root / "mcp-tokens")) is True
|
||||
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue