Protect Google Workspace profile OAuth tokens from downgrade
Cluster 4718 · optional-skills/productivity. Data as of
Nothing open on GitHub. Fix merged in #4910 on 4 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #4910. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #4910. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs address a fix to safeguard profile-scoped Google Workspace OAuth tokens from being downgraded by narrower re-authentication flows. The work appears to involve multiple attempts or iterations at landing the same protection.
Open issues0
Open PRs0
Fixes on record
-
#4910 fix: protect profile-scoped Google Workspace OAuth tokens (salvage #4719)
files:
skills/productivity/google-workspace/SKILL.md,skills/productivity/google-workspace/scripts/google_api.py,skills/productivity/google-workspace/scripts/setup.py,tests/skills/test_google_oauth_setup.py,tests/skills/test_google_workspace_api.py,website/docs/user-guide/security.mdShow the diff
-
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 -
skills/productivity/google-workspace/scripts/google_api.py@@ -22,13 +22,14 @@ 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 = [redacted:secret-assignment] / "google_token.json" SCOPES = [ @@ -43,6 +44,18 @@ ] +def _missing_scopes() -> list[str]: + try: + payload = json.loads(TOKEN_PATH.read_text()) + except Exception: + return [] + raw = payload.get("scopes") or payload.get("scope") + if not raw: + return [] + granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()} + 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 +73,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 -
skills/productivity/google-workspace/scripts/setup.py@@ -23,12 +23,13 @@ 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 = [redacted:secret-assignment] / "google_token.json" CLIENT_SECRET_PATH = [redacted:secret-assignment] / "google_client_secret.json" PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.json" @@ -52,6 +53,30 @@ REDIRECT_URI = "http://localhost:1" +def _load_token_payload(path: Path = TOKEN_PATH) -> dict: + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +def _missing_scopes_from_payload(payload: dict) -> list[str]: + raw = payload.get("scopes") or payload.get("scope") + if not raw: + return [] + granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()} + 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 +127,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 +140,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 +283,17 @@ def exchange_auth_code(code: str): sys.exit(1) creds = flow.credentials - TOKEN_PATH.write_text(creds.to_json()) + token_payload = [redacted:secret-assignment] + 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(): -
tests/skills/test_google_oauth_setup.py@@ -27,7 +27,16 @@ def __init__(self, payload=None): "token_uri": "[redacted:secret-assignment]", "client_id": "client-id", "client_secret": "[redacted:secret-assignment]", - "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 @@ def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, caps 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": "[redacted:secret-assignment]", "scopes": setup_module.SCOPES})) + FakeFlow.credentials_payload = { + "token": "[redacted:secret-assignment]", + "refresh_token": "[redacted:secret-assignment]", + "token_uri": "[redacted:secret-assignment]", + "client_id": "client-id", + "client_secret": "[redacted:secret-assignment]", + "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() -
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="[redacted:secret-assignment]"): + self.valid = valid + self.expired = expired + self.refresh_token = [redacted:secret-assignment] + 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": "[redacted:secret-assignment]", + "refresh_token": self.refresh_token, + "token_uri": "[redacted:secret-assignment]", + "client_id": "client-id", + "client_secret": "[redacted:secret-assignment]", + "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 + + [redacted:email] +def google_api_module(monkeypatch, tmp_path): + google_module = types.ModuleType("google") + oauth2_module = types.ModuleType("google.oauth2") + credentials_module = [redacted:secret-assignment]"google.oauth2.credentials") + credentials_module.Credentials = [redacted:secret-assignment] + 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": "[redacted:secret-assignment]", + "refresh_token": "[redacted:secret-assignment]", + "token_uri": "[redacted:secret-assignment]", + "client_id": "client-id", + "client_secret": "[redacted:secret-assignment]", + "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 -
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: [redacted:secret-assignment] 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)
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#4719 fix: protect profile-scoped Google Workspace OAuth tokens · merged via another change →
#4910
Closed issues · 1
- Closed#4718Protect Google Workspace profile OAuth tokens from narrower re-auth downgrades
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- optional-skills/productivity
- Merged PRs
- #4910
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 4 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.