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:
Teknium 2026-05-22 04:28:50 -07:00
parent 1f5219fda5
commit 42104218e0
2 changed files with 64 additions and 20 deletions

View file

@ -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)):

View file

@ -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
# =========================================================================