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
5 changes: 5 additions & 0 deletions agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level .env, even when running under a profile — overwriting it
# leaks credentials across every profile that inherits from root (#15981).
str(hermes_root / ".env"),
# Active profile Anthropic PKCE credential store.
str(hermes_home / ".anthropic_oauth.json"),
# 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"),
Expand Down
21 changes: 20 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import logging
import os
import secrets
import stat
import subprocess
import sys
import threading
Expand Down Expand Up @@ -1686,7 +1687,25 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
"expiresAt": expires_at_ms,
}
_HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
_HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
tmp_path = _HERMES_OAUTH_FILE.with_name(
f"{_HERMES_OAUTH_FILE.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
)
try:
with tmp_path.open("w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, indent=2))
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, _HERMES_OAUTH_FILE)
try:
_HERMES_OAUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
# Best-effort credential-pool insert. Failure here doesn't invalidate
# the file write — pool registration only matters for the rotation
# strategy, not for runtime credential resolution.
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"189280367+Lempkey@users.noreply.github.com": "Lempkey",
"34853915+m0n3r0@users.noreply.github.com": "m0n3r0",
"leeseoki@makestar.com": "leeseoki0",
"kronexoi13@gmail.com": "kronexoi",
"leovillalbajr@gmail.com": "Lempkey",
"nidhi2894@gmail.com": "nidhi-singh02",
"30312689+aashizpoudel@users.noreply.github.com": "aashizpoudel",
Expand Down
53 changes: 53 additions & 0 deletions tests/hermes_cli/test_web_server_oauth_write.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import os

import pytest

from hermes_cli.web_server import _save_anthropic_oauth_creds


class _DummyPool:
def entries(self):
return []

def remove_entry(self, _id):
return None

def add_entry(self, _entry):
return None


@pytest.fixture
def oauth_file(monkeypatch, tmp_path):
target = tmp_path / '.anthropic_oauth.json'
monkeypatch.setattr('agent.anthropic_adapter._HERMES_OAUTH_FILE', target)
monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool())
return target


def test_dashboard_oauth_write_uses_owner_only_permissions(oauth_file):
old_umask = os.umask(0o022)
try:
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)
finally:
os.umask(old_umask)

assert oauth_file.exists()
mode = oauth_file.stat().st_mode & 0o777
assert mode == 0o600


def test_dashboard_oauth_write_uses_atomic_replace_and_cleans_temp_files(oauth_file, monkeypatch):
replace_calls = []

def flaky_replace(src, dst):
replace_calls.append((src, dst))
raise OSError('simulated replace failure')

monkeypatch.setattr('hermes_cli.web_server.os.replace', flaky_replace)

with pytest.raises(OSError, match='simulated replace failure'):
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)

assert replace_calls, 'helper should attempt atomic os.replace()'
assert not oauth_file.exists()
assert not list(oauth_file.parent.glob(f'{oauth_file.name}.tmp*'))
19 changes: 11 additions & 8 deletions tests/tools/test_file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def test_tilde_expansion(self):
"auth.json",
"config.yaml",
"webhook_subscriptions.json",
".anthropic_oauth.json",
"mcp-tokens/token1.json",
"mcp-tokens/subdir/token2.json",
"pairing/telegram-approved.json",
Expand All @@ -74,8 +75,8 @@ def test_tilde_expansion(self):
"pairing",
],
)
def test_hermes_control_files_and_mcp_tokens_denied(self, path):
"""Hermes control files and mcp-tokens/pairing entries must be write-denied."""
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."""
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
full_path = str(hermes_home / path)
Expand All @@ -86,11 +87,12 @@ def test_hermes_control_files_and_mcp_tokens_denied(self, path):
[
"dummy/../config.yaml",
"./auth.json",
"./.anthropic_oauth.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."""
def test_hermes_control_files_and_oauth_traversal_denied(self, path):
"""Path traversal attempts to protected Hermes files must be blocked."""
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
full_path = str(hermes_home / path)
Expand All @@ -110,14 +112,15 @@ def test_standard_paths_allowed(self, path):

@pytest.mark.parametrize(
"name",
["auth.json", "config.yaml", "webhook_subscriptions.json"],
["auth.json", "config.yaml", "webhook_subscriptions.json", ".anthropic_oauth.json"],
)
def test_control_files_protected_in_profile_mode(self, tmp_path, monkeypatch, name):
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}
writable — the same gap PR #15981 fixed for .env.
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,...}
Expand Down
Loading