NousResearch/hermes-agent

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

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #4910 fix: protect profile-scoped Google Workspace OAuth tokens (salvage #4719)

    merged to main

    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

    Show 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.00 means 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 Lab

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

For your agent

The same facts, typed.