From 567ea61298f79d52c7e5fd478df5de3c0dafdf37 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:09:41 -0700 Subject: [PATCH] fix(file-safety): block auth.json read via TERMINAL_CWD relative path read_file_tool resolves relative paths against TERMINAL_CWD (or the task's live terminal cwd), but the prior call passed the original unresolved string to get_read_block_error. That function's own resolve() is anchored at the Python process cwd, so when a task's TERMINAL_CWD pointed at HERMES_HOME and the agent issued read_file on the relative path "auth.json", the credential-store denylist was never reached and the file was read normally. Pass the already-resolved absolute path string at the file_tools call site, document the contract on get_read_block_error, and add a read_file_tool-level regression test that pins the relative-path case under TERMINAL_CWD == HERMES_HOME. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/file_safety.py | 18 +++++++++++++- tests/agent/test_file_safety_credentials.py | 26 +++++++++++++++++++++ tools/file_tools.py | 9 +++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/agent/file_safety.py b/agent/file_safety.py index b3c49131ed3..e0d7876ae5d 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -136,7 +136,23 @@ def is_write_denied(path: str) -> bool: def get_read_block_error(path: str) -> Optional[str]: - """Return an error message when a read targets internal Hermes cache files.""" + """Return an error message when a read targets a denied Hermes path. + + Two categories are blocked: + * Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` — + readable metadata that an attacker could use as a prompt-injection + carrier. + * Credential stores at the top of ``HERMES_HOME`` (``auth.json``, + ``auth.lock``, ``.anthropic_oauth.json``) — plaintext provider + keys / OAuth tokens that the agent never needs to read directly. + + Callers that resolve relative paths against a non-process cwd + (e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve + and pass the absolute path string. This function's own ``resolve()`` + is anchored at the Python process cwd, so a relative input like + ``"auth.json"`` would otherwise miss the denylist when the task's + terminal cwd differs from the process cwd. + """ resolved = Path(path).expanduser().resolve() hermes_home = _hermes_home_path().resolve() blocked_dirs = [ diff --git a/tests/agent/test_file_safety_credentials.py b/tests/agent/test_file_safety_credentials.py index 1ec4d6aa525..f362249278d 100644 --- a/tests/agent/test_file_safety_credentials.py +++ b/tests/agent/test_file_safety_credentials.py @@ -121,3 +121,29 @@ def test_symlink_to_auth_json_blocked(fake_home, tmp_path): err = get_read_block_error(str(link)) assert err is not None assert "credential store" in err + + +def test_read_file_tool_blocks_relative_path_under_terminal_cwd( + fake_home, tmp_path, monkeypatch +): + """Bypass guard: a relative path like ``"auth.json"`` resolved by + ``read_file_tool`` against ``TERMINAL_CWD == HERMES_HOME`` must still + be blocked, even though ``get_read_block_error``'s own ``resolve()`` + is anchored at the (different) Python process cwd. + """ + import json + + import tools.file_tools as ft + + _create(fake_home, "auth.json") + # Force the file_tools resolver to anchor relative paths at HERMES_HOME + # while the Python process cwd remains tmp_path (a different directory). + monkeypatch.setenv("TERMINAL_CWD", str(fake_home)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + ft, "_get_live_tracking_cwd", lambda task_id="default": None + ) + + out = json.loads(ft.read_file_tool("auth.json")) + assert "error" in out + assert "credential store" in out["error"] diff --git a/tools/file_tools.py b/tools/file_tools.py index 2cedc4bcd5f..32dda0f82ee 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -474,8 +474,13 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = }) # ── Hermes internal path guard ──────────────────────────────── - # Prevent prompt injection via catalog or hub metadata files. - block_error = get_read_block_error(path) + # Prevent prompt injection via catalog or hub metadata files, + # and block credential stores under HERMES_HOME. Pass the + # already-resolved path so a relative-path read against + # TERMINAL_CWD == HERMES_HOME (e.g. "auth.json") still hits the + # denylist — get_read_block_error's own resolve() runs against + # the Python process cwd, which can differ. + block_error = get_read_block_error(str(_resolved)) if block_error: return json.dumps({"error": block_error})