{"repo":"NousResearch/hermes-agent","public_id":"4718","display_label":"Protect Google Workspace profile OAuth tokens from downgrade","summary":"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.","subsystem":"optional-skills/productivity","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4910,"merged_at":"2026-04-04T00:49:20+00:00"},"resolution":{"state":"resolved","by":"pr-4910"},"freshness":{"state":"stale","last_verified_at":"2026-04-04T00:49:20+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4718,"pr_number":4910,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-04T00:49:20+00:00"}],"members":[{"kind":"issue","number":4718,"title":"Protect Google Workspace profile OAuth tokens from narrower re-auth downgrades","url":null,"state":"closed"},{"kind":"pr","number":4719,"title":"fix: protect profile-scoped Google Workspace OAuth tokens","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4910","number":4910}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4910,"title":"fix: protect profile-scoped Google Workspace OAuth tokens (salvage #4719)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"skills/productivity/google-workspace/SKILL.md","hunks":["@@ -125,8 +125,9 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall\n \n ### Notes\n \n-- Token is stored at `~/.hermes/google_token.json` and auto-refreshes.\n-- Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes.\n+- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes.\n+- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes.\n+- 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.\n - To revoke: `$GSETUP --revoke`\n \n ## Usage"]},{"file":"skills/productivity/google-workspace/scripts/google_api.py","hunks":["@@ -22,13 +22,14 @@\n import argparse\n import base64\n import json\n-import os\n import sys\n from datetime import datetime, timedelta, timezone\n from email.mime.text import MIMEText\n from pathlib import Path\n \n-HERMES_HOME = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+from hermes_constants import display_hermes_home, get_hermes_home\n+\n+HERMES_HOME = get_hermes_home()\n TOKEN_PATH = [redacted:secret-assignment] / \"google_token.json\"\n \n SCOPES = [","@@ -43,6 +44,18 @@\n ]\n \n \n+def _missing_scopes() -> list[str]:\n+    try:\n+        payload = json.loads(TOKEN_PATH.read_text())\n+    except Exception:\n+        return []\n+    raw = payload.get(\"scopes\") or payload.get(\"scope\")\n+    if not raw:\n+        return []\n+    granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}\n+    return sorted(scope for scope in SCOPES if scope not in granted)\n+\n+\n def get_credentials():\n     \"\"\"Load and refresh credentials from token file.\"\"\"\n     if not TOKEN_PATH.exists():","@@ -60,6 +73,20 @@ def get_credentials():\n     if not creds.valid:\n         print(\"Token is invalid. Re-run setup.\", file=sys.stderr)\n         sys.exit(1)\n+\n+    missing_scopes = _missing_scopes()\n+    if missing_scopes:\n+        print(\n+            \"Token is valid but missing Google Workspace scopes required by this skill.\",\n+            file=sys.stderr,\n+        )\n+        for scope in missing_scopes:\n+            print(f\"  - {scope}\", file=sys.stderr)\n+        print(\n+            f\"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.\",\n+            file=sys.stderr,\n+        )\n+        sys.exit(1)\n     return creds\n \n "]},{"file":"skills/productivity/google-workspace/scripts/setup.py","hunks":["@@ -23,12 +23,13 @@\n \n import argparse\n import json\n-import os\n import subprocess\n import sys\n from pathlib import Path\n \n-HERMES_HOME = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+from hermes_constants import display_hermes_home, get_hermes_home\n+\n+HERMES_HOME = get_hermes_home()\n TOKEN_PATH = [redacted:secret-assignment] / \"google_token.json\"\n CLIENT_SECRET_PATH = [redacted:secret-assignment] / \"google_client_secret.json\"\n PENDING_AUTH_PATH = HERMES_HOME / \"google_oauth_pending.json\"","@@ -52,6 +53,30 @@\n REDIRECT_URI = \"http://localhost:1\"\n \n \n+def _load_token_payload(path: Path = TOKEN_PATH) -> dict:\n+    try:\n+        return json.loads(path.read_text())\n+    except Exception:\n+        return {}\n+\n+\n+def _missing_scopes_from_payload(payload: dict) -> list[str]:\n+    raw = payload.get(\"scopes\") or payload.get(\"scope\")\n+    if not raw:\n+        return []\n+    granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}\n+    return sorted(scope for scope in SCOPES if scope not in granted)\n+\n+\n+def _format_missing_scopes(missing_scopes: list[str]) -> str:\n+    bullets = \"\\n\".join(f\"  - {scope}\" for scope in missing_scopes)\n+    return (\n+        \"Token is valid but missing required Google Workspace scopes:\\n\"\n+        f\"{bullets}\\n\"\n+        \"Run the Google Workspace setup again from this same Hermes profile to refresh consent.\"\n+    )\n+\n+\n def install_deps():\n     \"\"\"Install Google API packages if missing. Returns True on success.\"\"\"\n     try:","@@ -102,7 +127,12 @@ def check_auth():\n         print(f\"TOKEN_CORRUPT: {e}\")\n         return False\n \n+    payload = _load_token_payload(TOKEN_PATH)\n     if creds.valid:\n+        missing_scopes = _missing_scopes_from_payload(payload)\n+        if missing_scopes:\n+            print(f\"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}\")\n+            return False\n         print(f\"AUTHENTICATED: Token valid at {TOKEN_PATH}\")\n         return True\n ","@@ -110,6 +140,10 @@ def check_auth():\n         try:\n             creds.refresh(Request())\n             TOKEN_PATH.write_text(creds.to_json())\n+            missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))\n+            if missing_scopes:\n+                print(f\"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}\")\n+                return False\n             print(f\"AUTHENTICATED: Token refreshed at {TOKEN_PATH}\")\n             return True\n         except Exception as e:","@@ -249,9 +283,17 @@ def exchange_auth_code(code: str):\n         sys.exit(1)\n \n     creds = flow.credentials\n-    TOKEN_PATH.write_text(creds.to_json())\n+    token_payload = [redacted:secret-assignment]\n+    missing_scopes = _missing_scopes_from_payload(token_payload)\n+    if missing_scopes:\n+        print(f\"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}\")\n+        print(f\"Existing token at {TOKEN_PATH} was left unchanged.\")\n+        sys.exit(1)\n+\n+    TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))\n     PENDING_AUTH_PATH.unlink(missing_ok=True)\n     print(f\"OK: Authenticated. Token saved to {TOKEN_PATH}\")\n+    print(f\"Profile-scoped token location: {display_hermes_home()}/google_token.json\")\n \n \n def revoke():"]},{"file":"tests/skills/test_google_oauth_setup.py","hunks":["@@ -27,7 +27,16 @@ def __init__(self, payload=None):\n             \"token_uri\": \"[redacted:secret-assignment]\",\n             \"client_id\": \"client-id\",\n             \"client_secret\": \"[redacted:secret-assignment]\",\n-            \"scopes\": [\"scope-a\"],\n+            \"scopes\": [\n+                \"https://www.googleapis.com/auth/gmail.readonly\",\n+                \"https://www.googleapis.com/auth/gmail.send\",\n+                \"https://www.googleapis.com/auth/gmail.modify\",\n+                \"https://www.googleapis.com/auth/calendar\",\n+                \"https://www.googleapis.com/auth/drive.readonly\",\n+                \"https://www.googleapis.com/auth/contacts.readonly\",\n+                \"https://www.googleapis.com/auth/spreadsheets\",\n+                \"https://www.googleapis.com/auth/documents.readonly\",\n+            ],\n         }\n \n     def to_json(self):","@@ -201,3 +210,28 @@ def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, caps\n         assert \"token exchange failed\" in out.lower()\n         assert setup_module.PENDING_AUTH_PATH.exists()\n         assert not setup_module.TOKEN_PATH.exists()\n+\n+    def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys):\n+        setup_module.PENDING_AUTH_PATH.write_text(\n+            json.dumps({\"state\": \"saved-state\", \"code_verifier\": \"saved-verifier\"})\n+        )\n+        setup_module.TOKEN_PATH.write_text(json.dumps({\"token\": \"[redacted:secret-assignment]\", \"scopes\": setup_module.SCOPES}))\n+        FakeFlow.credentials_payload = {\n+            \"token\": \"[redacted:secret-assignment]\",\n+            \"refresh_token\": \"[redacted:secret-assignment]\",\n+            \"token_uri\": \"[redacted:secret-assignment]\",\n+            \"client_id\": \"client-id\",\n+            \"client_secret\": \"[redacted:secret-assignment]\",\n+            \"scopes\": [\n+                \"https://www.googleapis.com/auth/drive.readonly\",\n+                \"https://www.googleapis.com/auth/spreadsheets\",\n+            ],\n+        }\n+\n+        with pytest.raises(SystemExit):\n+            setup_module.exchange_auth_code(\"4/test-auth-code\")\n+\n+        out = capsys.readouterr().out\n+        assert \"refusing to save incomplete google workspace token\" in out.lower()\n+        assert json.loads(setup_module.TOKEN_PATH.read_text())[\"token\"] == \"existing-token\"\n+        assert setup_module.PENDING_AUTH_PATH.exists()"]},{"file":"tests/skills/test_google_workspace_api.py","hunks":["@@ -0,0 +1,117 @@\n+\"\"\"Regression tests for Google Workspace API credential validation.\"\"\"\n+\n+import importlib.util\n+import json\n+import sys\n+import types\n+from pathlib import Path\n+\n+import pytest\n+\n+\n+SCRIPT_PATH = (\n+    Path(__file__).resolve().parents[2]\n+    / \"skills/productivity/google-workspace/scripts/google_api.py\"\n+)\n+\n+\n+class FakeAuthorizedCredentials:\n+    def __init__(self, *, valid=True, expired=False, refresh_token=\"[redacted:secret-assignment]\"):\n+        self.valid = valid\n+        self.expired = expired\n+        self.refresh_token = [redacted:secret-assignment]\n+        self.refresh_calls = 0\n+\n+    def refresh(self, _request):\n+        self.refresh_calls += 1\n+        self.valid = True\n+        self.expired = False\n+\n+    def to_json(self):\n+        return json.dumps({\n+            \"token\": \"[redacted:secret-assignment]\",\n+            \"refresh_token\": self.refresh_token,\n+            \"token_uri\": \"[redacted:secret-assignment]\",\n+            \"client_id\": \"client-id\",\n+            \"client_secret\": \"[redacted:secret-assignment]\",\n+            \"scopes\": [\n+                \"https://www.googleapis.com/auth/gmail.readonly\",\n+                \"https://www.googleapis.com/auth/gmail.send\",\n+                \"https://www.googleapis.com/auth/gmail.modify\",\n+                \"https://www.googleapis.com/auth/calendar\",\n+                \"https://www.googleapis.com/auth/drive.readonly\",\n+                \"https://www.googleapis.com/auth/contacts.readonly\",\n+                \"https://www.googleapis.com/auth/spreadsheets\",\n+                \"https://www.googleapis.com/auth/documents.readonly\",\n+            ],\n+        })\n+\n+\n+class FakeCredentialsFactory:\n+    creds = FakeAuthorizedCredentials()\n+\n+    @classmethod\n+    def from_authorized_user_file(cls, _path, _scopes):\n+        return cls.creds\n+\n+\n[redacted:email]\n+def google_api_module(monkeypatch, tmp_path):\n+    google_module = types.ModuleType(\"google\")\n+    oauth2_module = types.ModuleType(\"google.oauth2\")\n+    credentials_module = [redacted:secret-assignment]\"google.oauth2.credentials\")\n+    credentials_module.Credentials = [redacted:secret-assignment]\n+    auth_module = types.ModuleType(\"google.auth\")\n+    transport_module = types.ModuleType(\"google.auth.transport\")\n+    requests_module = types.ModuleType(\"google.auth.transport.requests\")\n+    requests_module.Request = object\n+\n+    monkeypatch.setitem(sys.modules, \"google\", google_module)\n+    monkeypatch.setitem(sys.modules, \"google.oauth2\", oauth2_module)\n+    monkeypatch.setitem(sys.modules, \"google.oauth2.credentials\", credentials_module)\n+    monkeypatch.setitem(sys.modules, \"google.auth\", auth_module)\n+    monkeypatch.setitem(sys.modules, \"google.auth.transport\", transport_module)\n+    monkeypatch.setitem(sys.modules, \"google.auth.transport.requests\", requests_module)\n+\n+    spec = importlib.util.spec_from_file_location(\"google_workspace_api_test\", SCRIPT_PATH)\n+    module = importlib.util.module_from_spec(spec)\n+    assert spec.loader is not None\n+    spec.loader.exec_module(module)\n+\n+    monkeypatch.setattr(module, \"TOKEN_PATH\", tmp_path / \"google_token.json\")\n+    return module\n+\n+\n+def _write_token(path: Path, scopes):\n+    path.write_text(json.dumps({\n+        \"token\": \"[redacted:secret-assignment]\",\n+        \"refresh_token\": \"[redacted:secret-assignment]\",\n+        \"token_uri\": \"[redacted:secret-assignment]\",\n+        \"client_id\": \"client-id\",\n+        \"client_secret\": \"[redacted:secret-assignment]\",\n+        \"scopes\": scopes,\n+    }))\n+\n+\n+def test_get_credentials_rejects_missing_scopes(google_api_module, capsys):\n+    FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)\n+    _write_token(google_api_module.TOKEN_PATH, [\n+        \"https://www.googleapis.com/auth/drive.readonly\",\n+        \"https://www.googleapis.com/auth/spreadsheets\",\n+    ])\n+\n+    with pytest.raises(SystemExit):\n+        google_api_module.get_credentials()\n+\n+    err = capsys.readouterr().err\n+    assert \"missing google workspace scopes\" in err.lower()\n+    assert \"gmail.send\" in err\n+\n+\n+def test_get_credentials_accepts_full_scope_token(google_api_module):\n+    FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)\n+    _write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES))\n+\n+    creds = google_api_module.get_credentials()\n+\n+    assert creds is FakeCredentialsFactory.creds"]},{"file":"website/docs/user-guide/security.md","hunks":["@@ -363,7 +363,7 @@ terminal:\n \n ### Credential File Passthrough (OAuth tokens, etc.) {#credential-file-passthrough}\n \n-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:\n+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:\n \n ```yaml\n required_credential_files:","@@ -373,7 +373,7 @@ required_credential_files:\n     [redacted:secret-assignment] Google OAuth2 client credentials\n ```\n \n-When loaded, Hermes checks if these files exist in `~/.hermes/` and registers them for mounting:\n+When loaded, Hermes checks if these files exist in the active profile's `HERMES_HOME` and registers them for mounting:\n \n - **Docker**: Read-only bind mounts (`-v host:container:ro`)\n - **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}