garc-gws-agent-runtime/scripts/garc-auth-helper.py
林 駿甫 (Shunsuke Hayashi) a69b9d9160 feat: initial release — GARC v0.1.0
Permission-first AI agent runtime for Google Workspace.
Ports the LARC/OpenClaw governance model (disclosure chain,
execution gates, queue/ingress) to Gmail, Calendar, Drive,
Sheets, Tasks, and People APIs with Claude Code as the
execution engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:59:12 +09:00

257 lines
9 KiB
Python
Executable file

#!/usr/bin/env python3
"""
GARC Auth Helper — OAuth scope inference and token management
Mirrors LARC's scope inference engine but for Google Workspace OAuth scopes
"""
import argparse
import json
import os
import sys
from pathlib import Path
GARC_DIR = Path(__file__).parent.parent
SCOPE_MAP_PATH = GARC_DIR / "config" / "scope-map.json"
GARC_CONFIG_DIR = Path.home() / ".garc"
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", str(GARC_CONFIG_DIR / "token.json")))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", str(GARC_CONFIG_DIR / "credentials.json")))
def load_scope_map():
with open(SCOPE_MAP_PATH) as f:
return json.load(f)
def suggest_scopes(task_description: str):
"""Infer minimum OAuth scopes for a natural-language task description."""
scope_map = load_scope_map()
task_description_lower = task_description.lower()
matched_tasks = []
matched_scopes = set()
# Keyword pattern matching
for task_type, patterns in scope_map.get("keyword_patterns", {}).items():
for pattern in patterns:
if pattern.lower() in task_description_lower:
if task_type not in matched_tasks:
matched_tasks.append(task_type)
task_def = scope_map["tasks"].get(task_type, {})
for scope in task_def.get("scopes", []):
matched_scopes.add(scope)
if not matched_tasks:
print("No specific task types matched. General writer profile recommended.")
print("\nSuggested profile: writer")
profile = scope_map["profiles"]["writer"]
print(f"Description: {profile['description']}")
print("\nScopes:")
for scope in profile["scopes"]:
print(f" - {scope}")
return
print(f"Task analysis: \"{task_description}\"")
print(f"\nMatched task types: {', '.join(matched_tasks)}")
# Show gate policies
print("\nExecution gates:")
for task_type in matched_tasks:
task_def = scope_map["tasks"].get(task_type, {})
gate = task_def.get("gate", "none")
desc = task_def.get("description", "")
gate_icon = {"none": "", "preview": "⚠️", "approval": "🔒"}.get(gate, "")
print(f" {gate_icon} {task_type} ({gate}): {desc}")
print("\nRequired OAuth scopes:")
for scope in sorted(matched_scopes):
print(f" - {scope}")
# Identity type
identities = set()
for task_type in matched_tasks:
task_def = scope_map["tasks"].get(task_type, {})
identities.add(task_def.get("identity", "user_access_token"))
print(f"\nIdentity type: {', '.join(identities)}")
# Highest gate level
gate_order = {"none": 0, "preview": 1, "approval": 2}
max_gate = max(
(scope_map["tasks"].get(t, {}).get("gate", "none") for t in matched_tasks),
key=lambda g: gate_order.get(g, 0),
default="none"
)
gate_messages = {
"none": "✅ All operations are read-only. Can execute immediately.",
"preview": "⚠️ Some operations have external visibility. Use --confirm flag.",
"approval": "🔒 High-risk operations detected. Human approval required before execution."
}
print(f"\nGate requirement: {gate_messages.get(max_gate, '')}")
# Recommend profile
print("\nRecommended garc auth login command:")
if max_gate == "none":
print(" garc auth login --profile readonly")
elif max_gate == "preview":
print(" garc auth login --profile writer")
else:
print(" garc auth login --profile backoffice_agent")
def check_scopes(profile: str):
"""Check if current token has the required scopes for a profile."""
scope_map = load_scope_map()
if profile not in scope_map.get("profiles", {}):
print(f"Unknown profile: {profile}")
print(f"Available profiles: {', '.join(scope_map['profiles'].keys())}")
sys.exit(1)
required_scopes = set(scope_map["profiles"][profile]["scopes"])
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
print(f"Run: garc auth login --profile {profile}")
sys.exit(1)
try:
with open(TOKEN_FILE) as f:
token_data = json.load(f)
current_scopes = set(token_data.get("scopes", "").split() if isinstance(token_data.get("scopes"), str)
else token_data.get("scopes", []))
except (json.JSONDecodeError, KeyError):
print(f"Could not read token file: {TOKEN_FILE}")
sys.exit(1)
missing = required_scopes - current_scopes
if not missing:
print(f"✅ Current token satisfies '{profile}' profile requirements.")
print(f" Required: {len(required_scopes)} scopes — all present.")
else:
print(f"❌ Missing scopes for '{profile}' profile:")
for scope in sorted(missing):
print(f" - {scope}")
print(f"\nRun: garc auth login --profile {profile}")
def login(profile: str):
"""Launch OAuth2 authorization flow for the given profile."""
scope_map = load_scope_map()
if profile not in scope_map.get("profiles", {}):
print(f"Unknown profile: {profile}")
sys.exit(1)
scopes = scope_map["profiles"][profile]["scopes"]
description = scope_map["profiles"][profile]["description"]
print(f"OAuth2 authorization for profile: {profile}")
print(f"Description: {description}")
print(f"\nRequested scopes ({len(scopes)}):")
for scope in scopes:
print(f" - {scope}")
if not CREDENTIALS_FILE.exists():
print(f"\nError: credentials.json not found at {CREDENTIALS_FILE}")
print("Download it from Google Cloud Console → APIs & Services → Credentials")
print("OAuth 2.0 Client IDs → Download JSON → save as ~/.garc/credentials.json")
sys.exit(1)
try:
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import google.oauth2.credentials
# Check if we have valid existing credentials
creds = None
if TOKEN_FILE.exists():
creds = google.oauth2.credentials.Credentials.from_authorized_user_file(str(TOKEN_FILE))
if creds and creds.valid:
print(f"\n✅ Already authenticated. Token file: {TOKEN_FILE}")
return
if creds and creds.expired and creds.refresh_token:
print("Refreshing expired token...")
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(str(CREDENTIALS_FILE), scopes)
creds = flow.run_local_server(port=0)
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(creds.to_json())
print(f"\n✅ Token saved to {TOKEN_FILE}")
except ImportError:
print("\nNote: google-auth-oauthlib not installed.")
print("Install with: pip install google-auth-oauthlib google-api-python-client")
print("\nManual authorization URL would use scopes:")
for scope in scopes:
print(f" {scope}")
def show_status():
"""Show current token information."""
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
print("Run: garc auth login --profile writer")
return
try:
with open(TOKEN_FILE) as f:
token_data = json.load(f)
print(f"Token file: {TOKEN_FILE}")
print(f"Client ID: {token_data.get('client_id', 'N/A')[:20]}...")
scopes = token_data.get("scopes", [])
if isinstance(scopes, str):
scopes = scopes.split()
print(f"\nGranted scopes ({len(scopes)}):")
for scope in sorted(scopes):
short = scope.replace("https://www.googleapis.com/auth/", "")
print(f" - {short}")
expiry = token_data.get("expiry", "unknown")
print(f"\nExpiry: {expiry}")
except Exception as e:
print(f"Error reading token: {e}")
def main():
parser = argparse.ArgumentParser(description="GARC Auth Helper")
subparsers = parser.add_subparsers(dest="command")
# suggest
suggest_parser = subparsers.add_parser("suggest", help="Suggest scopes for a task")
suggest_parser.add_argument("task", nargs="+", help="Task description")
# check
check_parser = subparsers.add_parser("check", help="Check current token scopes")
check_parser.add_argument("--profile", default="writer", help="Profile to check against")
# login
login_parser = subparsers.add_parser("login", help="Launch OAuth2 flow")
login_parser.add_argument("--profile", default="writer", help="Profile to authorize")
# status
subparsers.add_parser("status", help="Show token status")
args = parser.parse_args()
if args.command == "suggest":
suggest_scopes(" ".join(args.task))
elif args.command == "check":
check_scopes(args.profile)
elif args.command == "login":
login(args.profile)
elif args.command == "status":
show_status()
else:
parser.print_help()
if __name__ == "__main__":
main()