From 37e2ef6c3f31f16a61af4b166e954267ca8a8da1 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:55:45 +0530 Subject: [PATCH] fix: protect profile-scoped google workspace oauth tokens --- skills/productivity/google-workspace/SKILL.md | 5 +- .../google-workspace/scripts/google_api.py | 41 +++++- .../google-workspace/scripts/setup.py | 57 ++++++++- tests/skills/test_google_oauth_setup.py | 36 +++++- tests/skills/test_google_workspace_api.py | 117 ++++++++++++++++++ website/docs/user-guide/security.md | 4 +- 6 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 tests/skills/test_google_workspace_api.py diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 5d1c71bfb..6252c671e 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -125,8 +125,9 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall ### Notes -- Token is stored at `~/.hermes/google_token.json` and auto-refreshes. -- Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes. +- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes. +- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes. +- Hermes now refuses to overwrite a full Google Workspace token with a narrower re-auth token missing Gmail scopes, so one profile's partial consent cannot silently break email actions later. - To revoke: `$GSETUP --revoke` ## Usage diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 19c1159d2..207f8c737 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -22,13 +22,14 @@ Usage: import argparse import base64 import json -import os import sys from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText from pathlib import Path -HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +from hermes_constants import display_hermes_home, get_hermes_home + +HERMES_HOME = get_hermes_home() TOKEN_PATH = HERMES_HOME / "google_token.json" SCOPES = [ @@ -43,6 +44,28 @@ SCOPES = [ ] +def _load_token_payload() -> dict: + try: + return json.loads(TOKEN_PATH.read_text()) + except Exception: + return {} + + +def _normalize_scope_values(values) -> set[str]: + if not values: + return set() + if isinstance(values, str): + values = values.split() + return {str(value).strip() for value in values if str(value).strip()} + + +def _missing_scopes() -> list[str]: + granted = _normalize_scope_values(_load_token_payload().get("scopes") or _load_token_payload().get("scope")) + if not granted: + return [] + return sorted(scope for scope in SCOPES if scope not in granted) + + def get_credentials(): """Load and refresh credentials from token file.""" if not TOKEN_PATH.exists(): @@ -60,6 +83,20 @@ def get_credentials(): if not creds.valid: print("Token is invalid. Re-run setup.", file=sys.stderr) sys.exit(1) + + missing_scopes = _missing_scopes() + if missing_scopes: + print( + "Token is valid but missing Google Workspace scopes required by this skill.", + file=sys.stderr, + ) + for scope in missing_scopes: + print(f" - {scope}", file=sys.stderr) + print( + f"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.", + file=sys.stderr, + ) + sys.exit(1) return creds diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 14f9c6bf3..be27e1d35 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -23,12 +23,13 @@ Agent workflow: import argparse import json -import os import subprocess import sys from pathlib import Path -HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +from hermes_constants import display_hermes_home, get_hermes_home + +HERMES_HOME = get_hermes_home() TOKEN_PATH = HERMES_HOME / "google_token.json" CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.json" @@ -52,6 +53,39 @@ REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google REDIRECT_URI = "http://localhost:1" +def _load_token_payload(path: Path = TOKEN_PATH) -> dict: + try: + return json.loads(path.read_text()) + except FileNotFoundError: + return {} + except Exception: + return {} + + +def _normalize_scope_values(values) -> set[str]: + if not values: + return set() + if isinstance(values, str): + values = values.split() + return {str(value).strip() for value in values if str(value).strip()} + + +def _missing_scopes_from_payload(payload: dict) -> list[str]: + granted = _normalize_scope_values(payload.get("scopes") or payload.get("scope")) + if not granted: + return [] + return sorted(scope for scope in SCOPES if scope not in granted) + + +def _format_missing_scopes(missing_scopes: list[str]) -> str: + bullets = "\n".join(f" - {scope}" for scope in missing_scopes) + return ( + "Token is valid but missing required Google Workspace scopes:\n" + f"{bullets}\n" + "Run the Google Workspace setup again from this same Hermes profile to refresh consent." + ) + + def install_deps(): """Install Google API packages if missing. Returns True on success.""" try: @@ -102,7 +136,12 @@ def check_auth(): print(f"TOKEN_CORRUPT: {e}") return False + payload = _load_token_payload(TOKEN_PATH) if creds.valid: + missing_scopes = _missing_scopes_from_payload(payload) + if missing_scopes: + print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}") + return False print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}") return True @@ -110,6 +149,10 @@ def check_auth(): try: creds.refresh(Request()) TOKEN_PATH.write_text(creds.to_json()) + missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH)) + if missing_scopes: + print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}") + return False print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") return True except Exception as e: @@ -249,9 +292,17 @@ def exchange_auth_code(code: str): sys.exit(1) creds = flow.credentials - TOKEN_PATH.write_text(creds.to_json()) + token_payload = json.loads(creds.to_json()) + missing_scopes = _missing_scopes_from_payload(token_payload) + if missing_scopes: + print(f"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}") + print(f"Existing token at {TOKEN_PATH} was left unchanged.") + sys.exit(1) + + TOKEN_PATH.write_text(json.dumps(token_payload, indent=2)) PENDING_AUTH_PATH.unlink(missing_ok=True) print(f"OK: Authenticated. Token saved to {TOKEN_PATH}") + print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json") def revoke(): diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index 361bb7e28..a96e3d24e 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -27,7 +27,16 @@ class FakeCredentials: "token_uri": "https://oauth2.googleapis.com/token", "client_id": "client-id", "client_secret": "client-secret", - "scopes": ["scope-a"], + "scopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/documents.readonly", + ], } def to_json(self): @@ -201,3 +210,28 @@ class TestExchangeAuthCode: assert "token exchange failed" in out.lower() assert setup_module.PENDING_AUTH_PATH.exists() assert not setup_module.TOKEN_PATH.exists() + + def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys): + setup_module.PENDING_AUTH_PATH.write_text( + json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) + ) + setup_module.TOKEN_PATH.write_text(json.dumps({"token": "existing-token", "scopes": setup_module.SCOPES})) + FakeFlow.credentials_payload = { + "token": "narrow-token", + "refresh_token": "refresh-token", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "client-id", + "client_secret": "client-secret", + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/spreadsheets", + ], + } + + with pytest.raises(SystemExit): + setup_module.exchange_auth_code("4/test-auth-code") + + out = capsys.readouterr().out + assert "refusing to save incomplete google workspace token" in out.lower() + assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "existing-token" + assert setup_module.PENDING_AUTH_PATH.exists() diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py new file mode 100644 index 000000000..694bf4921 --- /dev/null +++ b/tests/skills/test_google_workspace_api.py @@ -0,0 +1,117 @@ +"""Regression tests for Google Workspace API credential validation.""" + +import importlib.util +import json +import sys +import types +from pathlib import Path + +import pytest + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/scripts/google_api.py" +) + + +class FakeAuthorizedCredentials: + def __init__(self, *, valid=True, expired=False, refresh_token="refresh-token"): + self.valid = valid + self.expired = expired + self.refresh_token = refresh_token + self.refresh_calls = 0 + + def refresh(self, _request): + self.refresh_calls += 1 + self.valid = True + self.expired = False + + def to_json(self): + return json.dumps({ + "token": "refreshed-token", + "refresh_token": self.refresh_token, + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "client-id", + "client_secret": "client-secret", + "scopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/documents.readonly", + ], + }) + + +class FakeCredentialsFactory: + creds = FakeAuthorizedCredentials() + + @classmethod + def from_authorized_user_file(cls, _path, _scopes): + return cls.creds + + +@pytest.fixture +def google_api_module(monkeypatch, tmp_path): + google_module = types.ModuleType("google") + oauth2_module = types.ModuleType("google.oauth2") + credentials_module = types.ModuleType("google.oauth2.credentials") + credentials_module.Credentials = FakeCredentialsFactory + auth_module = types.ModuleType("google.auth") + transport_module = types.ModuleType("google.auth.transport") + requests_module = types.ModuleType("google.auth.transport.requests") + requests_module.Request = object + + monkeypatch.setitem(sys.modules, "google", google_module) + monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module) + monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module) + monkeypatch.setitem(sys.modules, "google.auth", auth_module) + monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module) + monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module) + + spec = importlib.util.spec_from_file_location("google_workspace_api_test", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + + monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json") + return module + + +def _write_token(path: Path, scopes): + path.write_text(json.dumps({ + "token": "access-token", + "refresh_token": "refresh-token", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "client-id", + "client_secret": "client-secret", + "scopes": scopes, + })) + + +def test_get_credentials_rejects_missing_scopes(google_api_module, capsys): + FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True) + _write_token(google_api_module.TOKEN_PATH, [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/spreadsheets", + ]) + + with pytest.raises(SystemExit): + google_api_module.get_credentials() + + err = capsys.readouterr().err + assert "missing google workspace scopes" in err.lower() + assert "gmail.send" in err + + +def test_get_credentials_accepts_full_scope_token(google_api_module): + FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True) + _write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES)) + + creds = google_api_module.get_credentials() + + assert creds is FakeCredentialsFactory.creds diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 195583639..22e76b5a2 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -363,7 +363,7 @@ terminal: ### Credential File Passthrough (OAuth tokens, etc.) {#credential-file-passthrough} -Some skills need **files** (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as `google_token.json` in `~/.hermes/`. Skills declare these in frontmatter: +Some skills need **files** (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as `google_token.json` under the active profile's `HERMES_HOME`. Skills declare these in frontmatter: ```yaml required_credential_files: @@ -373,7 +373,7 @@ required_credential_files: description: Google OAuth2 client credentials ``` -When loaded, Hermes checks if these files exist in `~/.hermes/` and registers them for mounting: +When loaded, Hermes checks if these files exist in the active profile's `HERMES_HOME` and registers them for mounting: - **Docker**: Read-only bind mounts (`-v host:container:ro`) - **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)