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 ### Notes
- Token is stored at `~/.hermes/google_token.json` and auto-refreshes. - 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 `~/.hermes/google_oauth_pending.json` until exchange completes. - 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` - To revoke: `$GSETUP --revoke`
## Usage ## Usage

View File

@@ -22,13 +22,14 @@ Usage:
import argparse import argparse
import base64 import base64
import json import json
import os
import sys import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText from email.mime.text import MIMEText
from pathlib import Path 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" TOKEN_PATH = HERMES_HOME / "google_token.json"
SCOPES = [ 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(): def get_credentials():
"""Load and refresh credentials from token file.""" """Load and refresh credentials from token file."""
if not TOKEN_PATH.exists(): if not TOKEN_PATH.exists():
@@ -60,6 +83,20 @@ def get_credentials():
if not creds.valid: if not creds.valid:
print("Token is invalid. Re-run setup.", file=sys.stderr) print("Token is invalid. Re-run setup.", file=sys.stderr)
sys.exit(1) 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 return creds

View File

@@ -23,12 +23,13 @@ Agent workflow:
import argparse import argparse
import json import json
import os
import subprocess import subprocess
import sys import sys
from pathlib import Path 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" TOKEN_PATH = HERMES_HOME / "google_token.json"
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"
PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.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" 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(): def install_deps():
"""Install Google API packages if missing. Returns True on success.""" """Install Google API packages if missing. Returns True on success."""
try: try:
@@ -102,7 +136,12 @@ def check_auth():
print(f"TOKEN_CORRUPT: {e}") print(f"TOKEN_CORRUPT: {e}")
return False return False
payload = _load_token_payload(TOKEN_PATH)
if creds.valid: 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}") print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
return True return True
@@ -110,6 +149,10 @@ def check_auth():
try: try:
creds.refresh(Request()) creds.refresh(Request())
TOKEN_PATH.write_text(creds.to_json()) 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}") print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
return True return True
except Exception as e: except Exception as e:
@@ -249,9 +292,17 @@ def exchange_auth_code(code: str):
sys.exit(1) sys.exit(1)
creds = flow.credentials 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) PENDING_AUTH_PATH.unlink(missing_ok=True)
print(f"OK: Authenticated. Token saved to {TOKEN_PATH}") print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")
print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")
def revoke(): def revoke():

View File

@@ -27,7 +27,16 @@ class FakeCredentials:
"token_uri": "https://oauth2.googleapis.com/token", "token_uri": "https://oauth2.googleapis.com/token",
"client_id": "client-id", "client_id": "client-id",
"client_secret": "client-secret", "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): def to_json(self):
@@ -201,3 +210,28 @@ class TestExchangeAuthCode:
assert "token exchange failed" in out.lower() assert "token exchange failed" in out.lower()
assert setup_module.PENDING_AUTH_PATH.exists() assert setup_module.PENDING_AUTH_PATH.exists()
assert not setup_module.TOKEN_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} ### 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 ```yaml
required_credential_files: required_credential_files:
@@ -373,7 +373,7 @@ required_credential_files:
description: Google OAuth2 client credentials 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`) - **Docker**: Read-only bind mounts (`-v host:container:ro`)
- **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup) - **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)