diff --git a/agent/file_safety.py b/agent/file_safety.py index 0c6e4cc8da6..d2b830a1970 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -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 /auth.json + /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)): diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index 266921ebfab..db4f490f73b 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -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 /X and /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: + # /profiles/coder/{auth.json,config.yaml,...} + # /{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 + # =========================================================================