Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
os.path.join(home, ".bashrc"),
os.path.join(home, ".zshrc"),
os.path.join(home, ".profile"),
os.path.join(home, ".bash_profile"),
os.path.join(home, ".zprofile"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
Expand Down Expand Up @@ -104,12 +99,6 @@ def is_write_denied(path: str) -> bool:
if resolved.startswith(prefix):
return True

# 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"

hermes_dirs = []
Expand All @@ -122,12 +111,6 @@ def is_write_denied(path: str) -> bool:
continue

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):
Expand Down
59 changes: 33 additions & 26 deletions tests/tools/test_file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ def test_netrc_denied(self):
path = os.path.join(str(Path.home()), ".netrc")
assert _is_write_denied(path) is True

@pytest.mark.parametrize("name", [".pgpass", ".npmrc", ".pypirc"])
def test_credential_config_files_denied(self, name):
path = os.path.join(str(Path.home()), name)
assert _is_write_denied(path) is True

def test_aws_prefix_denied(self):
path = os.path.join(str(Path.home()), ".aws", "credentials")
assert _is_write_denied(path) is True
Expand All @@ -59,9 +64,6 @@ def test_tilde_expansion(self):
@pytest.mark.parametrize(
"path",
[
"auth.json",
"config.yaml",
"webhook_subscriptions.json",
".anthropic_oauth.json",
"mcp-tokens/token1.json",
"mcp-tokens/subdir/token2.json",
Expand All @@ -71,24 +73,30 @@ def test_tilde_expansion(self):
"pairing",
],
)
def test_hermes_control_files_oauth_and_mcp_tokens_denied(self, path):
"""Hermes control files, PKCE creds, mcp-tokens, and pairing entries must be write-denied."""
def test_oauth_mcp_tokens_and_pairing_denied(self, path):
"""PKCE creds, mcp-tokens, and pairing 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",
["auth.json", "config.yaml", "webhook_subscriptions.json"],
)
def test_hermes_control_files_requested_writable(self, path):
from hermes_constants import get_hermes_home

assert _is_write_denied(str(get_hermes_home() / path)) is False

@pytest.mark.parametrize(
"path",
[
"dummy/../config.yaml",
"./auth.json",
"./.anthropic_oauth.json",
"mcp-tokens/../config.yaml",
],
)
def test_hermes_control_files_and_oauth_traversal_denied(self, path):
"""Path traversal attempts to protected Hermes files must be blocked."""
def test_oauth_traversal_denied(self, path):
"""Path traversal attempts to protected OAuth files must be blocked."""
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
full_path = str(hermes_home / path)
Expand All @@ -106,30 +114,29 @@ def test_standard_paths_allowed(self, path):
"""Unrelated paths must still be allowed."""
assert _is_write_denied(path) is False

@pytest.mark.parametrize("name", [".anthropic_oauth.json"])
def test_oauth_protected_in_profile_mode(self, tmp_path, monkeypatch, name):
"""Under a profile, BOTH <profile>/X and <root>/X must 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 / name)) is True
assert _is_write_denied(str(root / name)) is True

@pytest.mark.parametrize(
"name",
["auth.json", "config.yaml", "webhook_subscriptions.json", ".anthropic_oauth.json"],
["auth.json", "config.yaml", "webhook_subscriptions.json"],
)
def test_control_files_and_oauth_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,
.anthropic_oauth.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
def test_control_files_requested_writable_in_profile_mode(self, tmp_path, monkeypatch, name):
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
assert _is_write_denied(str(profile / name)) is False
assert _is_write_denied(str(root / name)) is False

def test_mcp_tokens_dir_protected_in_profile_mode(self, tmp_path, monkeypatch):
"""mcp-tokens/ under profile AND under root must both be denied."""
Expand Down
20 changes: 10 additions & 10 deletions tests/tools/test_write_deny.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ def test_ssh_id_ed25519(self):
path = os.path.join(str(Path.home()), ".ssh", "id_ed25519")
assert _is_write_denied(path) is True

def test_netrc(self):
path = os.path.join(str(Path.home()), ".netrc")
assert _is_write_denied(path) is True

def test_hermes_env(self):
# ``.env`` under the active HERMES_HOME (profile-aware, not just
Expand Down Expand Up @@ -67,14 +64,14 @@ def test_hermes_root_env_when_running_under_profile(self, tmp_path, monkeypatch)

assert _is_write_denied(str(global_env)) is True

def test_shell_profiles(self):
def test_shell_profiles_are_writable(self):
home = str(Path.home())
for name in [".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile"]:
assert _is_write_denied(os.path.join(home, name)) is True, f"{name} should be denied"
assert _is_write_denied(os.path.join(home, name)) is False, f"{name} should be writable"

def test_package_manager_configs(self):
def test_credential_config_files_denied(self):
home = str(Path.home())
for name in [".npmrc", ".pypirc", ".pgpass"]:
for name in [".netrc", ".pgpass", ".npmrc", ".pypirc"]:
assert _is_write_denied(os.path.join(home, name)) is True, f"{name} should be denied"


Expand Down Expand Up @@ -123,6 +120,9 @@ def test_tmp_file(self):
def test_project_file(self):
assert _is_write_denied("/home/user/project/main.py") is False

def test_hermes_config_not_env(self):
path = os.path.join(str(Path.home()), ".hermes", "config.yaml")
assert _is_write_denied(path) is False
def test_hermes_control_files_requested_writable(self):
from hermes_constants import get_hermes_home

home = get_hermes_home()
for name in ["auth.json", "config.yaml", "webhook_subscriptions.json"]:
assert _is_write_denied(str(home / name)) is False, f"{name} should be writable"
Loading