fix: protect profile-scoped google workspace oauth tokens

This commit is contained in:
kshitijk4poor
2026-04-03 11:55:45 +05:30
committed by Teknium
parent 92dcdbff66
commit 37e2ef6c3f
6 changed files with 250 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

@@ -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)