fix(file-safety): deny reads of Google OAuth tokens (#30972)
This commit is contained in:
@@ -153,11 +153,11 @@ def get_read_block_error(path: str) -> Optional[str]:
|
|||||||
carrier.
|
carrier.
|
||||||
* Credential / secret stores under HERMES_HOME and the global Hermes
|
* Credential / secret stores under HERMES_HOME and the global Hermes
|
||||||
root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,
|
root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,
|
||||||
``.env``, ``webhook_subscriptions.json``, and anything under
|
``.env``, ``webhook_subscriptions.json``, ``auth/google_oauth.json``,
|
||||||
``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens,
|
and anything under ``mcp-tokens/``. These hold plaintext provider keys,
|
||||||
and HMAC secrets that the agent never needs to read directly —
|
OAuth tokens, and HMAC secrets that the agent never needs to read
|
||||||
provider tools / gateway adapters consume them through internal
|
directly — provider tools / gateway adapters consume them through
|
||||||
channels.
|
internal channels.
|
||||||
|
|
||||||
**This is NOT a security boundary.** The terminal tool runs as the
|
**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``
|
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",
|
".anthropic_oauth.json",
|
||||||
".env",
|
".env",
|
||||||
"webhook_subscriptions.json",
|
"webhook_subscriptions.json",
|
||||||
|
os.path.join("auth", "google_oauth.json"),
|
||||||
)
|
)
|
||||||
for hd in hermes_dirs:
|
for hd in hermes_dirs:
|
||||||
for name in credential_file_names:
|
for name in credential_file_names:
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ def test_anthropic_oauth_json_blocked(fake_home):
|
|||||||
assert "credential store" in err
|
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):
|
def test_arbitrary_hermes_home_file_not_blocked(fake_home):
|
||||||
"""Non-credential files inside HERMES_HOME stay readable."""
|
"""Non-credential files inside HERMES_HOME stay readable."""
|
||||||
from agent.file_safety import get_read_block_error
|
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"]
|
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/
|
# 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"
|
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 = project / "mcp-tokens"
|
||||||
tokens.mkdir()
|
tokens.mkdir()
|
||||||
tok_file = tokens / "token.json"
|
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
|
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):
|
def test_config_yaml_not_blocked(fake_home):
|
||||||
"""config.yaml is NOT a credential file — agent should still be
|
"""config.yaml is NOT a credential file — agent should still be
|
||||||
able to read it for debugging. (Writes are denied separately by
|
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")
|
root_env.write_text("x")
|
||||||
assert "credential store" in (get_read_block_error(str(root_env)) or "")
|
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-level mcp-tokens: blocked
|
||||||
root_tok = root / "mcp-tokens" / "gh.json"
|
root_tok = root / "mcp-tokens" / "gh.json"
|
||||||
root_tok.parent.mkdir(parents=True, exist_ok=True)
|
root_tok.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user