Skip to content
Open
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
63 changes: 62 additions & 1 deletion agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,42 @@ def build_write_denied_prefixes(home: str) -> list[str]:
]


def build_read_denied_paths(home: str) -> set[str]:
"""Return exact per-user credential files that must never be read."""
return {
os.path.realpath(p)
for p in [
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
os.path.join(home, ".pypirc"),
os.path.join(home, ".git-credentials"),
]
}


def build_read_denied_prefixes(home: str) -> list[str]:
"""Return per-user credential directories that must never be read."""
return [
os.path.realpath(p)
for p in [
os.path.join(home, ".ssh"),
os.path.join(home, ".aws"),
os.path.join(home, ".gnupg"),
os.path.join(home, ".kube"),
os.path.join(home, ".docker"),
os.path.join(home, ".azure"),
os.path.join(home, ".config", "gh"),
os.path.join(home, ".config", "gcloud"),
]
]


def _is_at_or_under(path: str, root: str) -> bool:
"""Return True when ``path`` is exactly ``root`` or inside it."""
return path == root or path.startswith(root + os.sep)


def get_safe_write_root() -> Optional[str]:
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
Expand Down Expand Up @@ -165,7 +201,7 @@ def is_write_denied(path: str) -> bool:
def get_read_block_error(path: str) -> Optional[str]:
"""Return an error message when a read targets a denied Hermes path.

Three categories are blocked:
Four categories are blocked:

* Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` —
readable metadata that an attacker could use as a prompt-injection
Expand All @@ -184,6 +220,9 @@ def get_read_block_error(path: str) -> Optional[str]:
own projects. The agent helping debug a project shouldn't normally
need to read these — ``.env.example`` is the documented-shape
substitute.
* Common per-user credential stores under the OS home directory, such as
``~/.ssh/``, ``~/.aws/``, ``~/.kube/``, ``~/.docker/``, ``~/.netrc``,
``~/.npmrc``, and GitHub / gcloud credential stores.

**This is NOT a security boundary.** The terminal tool runs as the
same OS user with shell access; the agent can still ``cat auth.json``
Expand All @@ -208,6 +247,7 @@ def get_read_block_error(path: str) -> Optional[str]:
terminal cwd differs from the process cwd.
"""
resolved = Path(path).expanduser().resolve()
resolved_str = os.path.realpath(str(resolved))

# Resolve BOTH the active HERMES_HOME (profile-aware) AND the global
# Hermes root so credential stores at <root>/auth.json etc. are also
Expand Down Expand Up @@ -292,6 +332,27 @@ def get_read_block_error(path: str) -> Optional[str]:
"security boundary; the terminal tool can still bypass.)"
)

# Common user credential stores outside HERMES_HOME. These mirror the
# sensitive user files/directories that write_file already treats as
# protected, while intentionally avoiding broad shell rc files and system
# files so normal debugging reads remain available.
home = os.path.realpath(os.path.expanduser("~"))
if resolved_str in build_read_denied_paths(home):
return (
f"Access denied: {path} is a user credential store "
"and cannot be read directly. Use the relevant provider, auth, "
"or platform tool instead. (Defense-in-depth — not a security "
"boundary; the terminal tool can still bypass.)"
)
for prefix in build_read_denied_prefixes(home):
if _is_at_or_under(resolved_str, prefix):
return (
f"Access denied: {path} is inside a user credential store "
"and cannot be read directly. Use the relevant provider, auth, "
"or platform tool instead. (Defense-in-depth — not a security "
"boundary; the terminal tool can still bypass.)"
)

# Block common secret-bearing project-local .env files anywhere on disk.
# The agent helping a user with their project rarely needs to read raw
# .env contents — .env.example is the documented-shape substitute. The
Expand Down
107 changes: 107 additions & 0 deletions tests/agent/test_file_safety_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ def fake_home(tmp_path, monkeypatch):
return home


@pytest.fixture()
def fake_user_home(tmp_path, monkeypatch):
"""Point OS home expansion at a tmp dir for user-credential checks."""
home = tmp_path / "user_home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
return home


def _create(home: Path, rel: str | Path) -> Path:
"""Create the file (with parents) so realpath() resolves it."""
p = home / rel
Expand Down Expand Up @@ -295,6 +305,103 @@ def test_config_yaml_not_blocked(fake_home):
assert get_read_block_error(str(cfg)) is None


@pytest.mark.parametrize(
"relpath",
[
Path(".ssh") / "id_rsa",
Path(".ssh") / "config",
Path(".aws") / "credentials",
Path(".gnupg") / "pubring.kbx",
Path(".kube") / "config",
Path(".docker") / "config.json",
Path(".azure") / "azureProfile.json",
Path(".config") / "gh" / "hosts.yml",
Path(".config") / "gcloud" / "application_default_credentials.json",
".netrc",
".pgpass",
".npmrc",
".pypirc",
".git-credentials",
],
)
def test_user_credential_stores_blocked(fake_user_home, relpath):
"""Common per-user credential stores must not be readable via read_file."""
from agent.file_safety import get_read_block_error

secret = _create(fake_user_home, relpath)
err = get_read_block_error(str(secret))

assert err is not None
assert "user credential store" in err


def test_non_credential_home_file_not_blocked(fake_user_home):
"""The user-home guard should not block arbitrary non-secret files."""
from agent.file_safety import get_read_block_error

note = _create(fake_user_home, "notes.txt")

assert get_read_block_error(str(note)) is None


def test_read_file_tool_blocks_user_credential_store(
fake_user_home, tmp_path, monkeypatch
):
"""The real read_file tool must not return ~/.aws/credentials content."""
import json

import tools.file_tools as ft

credentials = _create(fake_user_home, Path(".aws") / "credentials")
credentials.write_text(
"[default]\naws_secret_access_key = SHOULD_NOT_LEAK\n",
encoding="utf-8",
)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
ft, "_get_live_tracking_cwd", lambda task_id="default": None
)
monkeypatch.setattr(
ft,
"_get_file_ops",
lambda task_id="default": pytest.fail("read_file should be blocked before I/O"),
)

out = json.loads(ft.read_file_tool(str(credentials), task_id="aws-creds-test"))

assert "error" in out
assert "user credential store" in out["error"]
assert "SHOULD_NOT_LEAK" not in json.dumps(out)


def test_read_file_tool_blocks_relative_user_credential_store(
fake_user_home, tmp_path, monkeypatch
):
"""Relative task paths under the terminal cwd must hit the same guard."""
import json

import tools.file_tools as ft

credentials = _create(fake_user_home, Path(".ssh") / "id_rsa")
credentials.write_text("SSH_PRIVATE_KEY_MARKER", encoding="utf-8")
monkeypatch.setenv("TERMINAL_CWD", str(fake_user_home))
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
ft, "_get_live_tracking_cwd", lambda task_id="default": None
)
monkeypatch.setattr(
ft,
"_get_file_ops",
lambda task_id="default": pytest.fail("read_file should be blocked before I/O"),
)

out = json.loads(ft.read_file_tool(".ssh/id_rsa", task_id="ssh-creds-test"))

assert "error" in out
assert "user credential store" in out["error"]
assert "SSH_PRIVATE_KEY_MARKER" not in json.dumps(out)


def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch):
"""Under a profile, HERMES_HOME = <root>/profiles/<name>, but
<root>/auth.json must ALSO be blocked — credentials at root are
Expand Down
Loading