# NousResearch/hermes-agent · Protect Google Workspace profile OAuth tokens from downgrade

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #4910. Verify it addresses your case.
- **Recommended:** read PR #4910 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-4910`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #4718: Protect Google Workspace profile OAuth tokens from narrower re-auth downgrades

## Prior & competing PRs

- **#4910: fix: protect profile-scoped Google Workspace OAuth tokens (salvage #4719)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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.md`
  - fix hunks:
  - `skills/productivity/google-workspace/SKILL.md`

    ```diff
    @@ -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`

    ```diff
    @@ -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 = [
    ```

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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)
    ```
- **#4719: fix: protect profile-scoped Google Workspace OAuth tokens**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#4910`

## Fix status

- Merged to main in PR #4910 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-04`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `optional-skills/productivity`
