diff --git a/agent/file_safety.py b/agent/file_safety.py index 64f52e2ecd2..efe7de4c183 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -153,11 +153,11 @@ def get_read_block_error(path: str) -> Optional[str]: carrier. * Credential / secret stores under HERMES_HOME and the global Hermes root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``, - ``.env``, ``webhook_subscriptions.json``, and anything under - ``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens, - and HMAC secrets that the agent never needs to read directly — - provider tools / gateway adapters consume them through internal - channels. + ``.env``, ``webhook_subscriptions.json``, ``auth/google_oauth.json``, + and anything under ``mcp-tokens/``. These hold plaintext provider keys, + OAuth tokens, and HMAC secrets that the agent never needs to read + directly — provider tools / gateway adapters consume them through + internal channels. **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`` @@ -222,6 +222,7 @@ def get_read_block_error(path: str) -> Optional[str]: ".anthropic_oauth.json", ".env", "webhook_subscriptions.json", + os.path.join("auth", "google_oauth.json"), ) for hd in hermes_dirs: for name in credential_file_names: diff --git a/tests/agent/test_file_safety_credentials.py b/tests/agent/test_file_safety_credentials.py index 94cf82f2ccd..30199892c84 100644 --- a/tests/agent/test_file_safety_credentials.py +++ b/tests/agent/test_file_safety_credentials.py @@ -66,6 +66,16 @@ def test_anthropic_oauth_json_blocked(fake_home): assert "credential store" in err +def test_google_oauth_json_blocked(fake_home): + """Gemini OAuth tokens live under auth/google_oauth.json — blocked.""" + from agent.file_safety import get_read_block_error + + oauth = _create(fake_home, Path("auth") / "google_oauth.json") + err = get_read_block_error(str(oauth)) + assert err is not None + assert "credential store" in err + + def test_arbitrary_hermes_home_file_not_blocked(fake_home): """Non-credential files inside HERMES_HOME stay readable.""" from agent.file_safety import get_read_block_error @@ -149,6 +159,37 @@ def test_read_file_tool_blocks_relative_path_under_terminal_cwd( assert "credential store" in out["error"] +def test_read_file_tool_blocks_nested_google_oauth_path( + fake_home, tmp_path, monkeypatch +): + """The real read_file tool must not return Gemini OAuth token material.""" + import json + + import tools.file_tools as ft + + oauth = _create(fake_home, Path("auth") / "google_oauth.json") + oauth.write_text( + json.dumps( + { + "refresh": "REFRESH_TOKEN_MARKER", + "access": "ACCESS_TOKEN_MARKER", + "email": "user@example.com", + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + ft, "_get_live_tracking_cwd", lambda task_id="default": None + ) + + out = json.loads(ft.read_file_tool(str(oauth), task_id="google-oauth-test")) + assert "error" in out + assert "credential store" in out["error"] + assert "REFRESH_TOKEN_MARKER" not in json.dumps(out) + assert "ACCESS_TOKEN_MARKER" not in json.dumps(out) + + # --------------------------------------------------------------------------- # Widening: .env, webhook_subscriptions.json, mcp-tokens/ # --------------------------------------------------------------------------- @@ -222,6 +263,11 @@ def test_identically_named_files_outside_hermes_home_not_blocked( f"{rel} outside HERMES_HOME should NOT be blocked" ) + google_oauth = project / "auth" / "google_oauth.json" + google_oauth.parent.mkdir() + google_oauth.write_text("not really a token", encoding="utf-8") + assert get_read_block_error(str(google_oauth)) is None + tokens = project / "mcp-tokens" tokens.mkdir() tok_file = tokens / "token.json" @@ -229,6 +275,14 @@ def test_identically_named_files_outside_hermes_home_not_blocked( assert get_read_block_error(str(tok_file)) is None +def test_non_secret_auth_subtree_file_not_blocked(fake_home): + """Only the known Google OAuth token path is blocked, not all auth/*.""" + from agent.file_safety import get_read_block_error + + note = _create(fake_home, Path("auth") / "notes.json") + assert get_read_block_error(str(note)) is None + + def test_config_yaml_not_blocked(fake_home): """config.yaml is NOT a credential file — agent should still be able to read it for debugging. (Writes are denied separately by @@ -268,6 +322,14 @@ def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch): root_env.write_text("x") assert "credential store" in (get_read_block_error(str(root_env)) or "") + # Root-level Google OAuth token store: blocked too + root_google_oauth = root / "auth" / "google_oauth.json" + root_google_oauth.parent.mkdir(parents=True, exist_ok=True) + root_google_oauth.write_text("x") + assert "credential store" in ( + get_read_block_error(str(root_google_oauth)) or "" + ) + # Root-level mcp-tokens: blocked root_tok = root / "mcp-tokens" / "gh.json" root_tok.parent.mkdir(parents=True, exist_ok=True)