Skip to main content
GAIA’s gaia.connectors package implements OAuth 2.0 PKCE (RFC 7636/8252) for desktop authorization flows. This page describes the threat model, what we protect against, and the residual risks a user or operator should know about.

What we protect

Refresh tokens never leave the OS credential store. The gaia.connectors.store module writes to:
  • macOS Keychain (built-in)
  • Windows Credential Locker (built-in)
  • Linux SecretService (gnome-keyring or kwallet)
Plaintext fallbacks (keyrings.alt.PlaintextKeyring, EncryptedKeyring) are explicitly refused at the entry of every save and load (plan amendment A4). A Linux user without SecretService sees an actionable error pointing at this page rather than silently writing tokens to disk. Refresh tokens never cross a public API or response body. The public gaia.connectors.list_connections() returns only metadata (provider, account_email, scopes, connected_at). The FastAPI router enforces the same boundary on every JSON response. The OpenAPI schema is exercised in tests/unit/connectors/test_secret_hygiene.py to enforce this in CI. OAuth state parameter is per-flow random and compared in constant time. state = secrets.token_urlsafe(32); the callback handler compares received state with hmac.compare_digest. Mismatched or missing state returns 400 with a static error page — no echoed user input. Loopback redirect on 127.0.0.1, not localhost. Prevents DNS rebinding. Bound on an ephemeral port (port=0) per flow. Two-layer authorization for get_access_token. Before any access token is returned to a tool body:
  1. The named agent must have a per-agent grant covering the requested scopes (in ~/.gaia/connectors/grants.json).
  2. The stored OAuth connection must actually carry those scopes (the user authorized them).
A missing grant raises AuthRequiredError(AGENT_NOT_GRANTED); a missing OAuth scope raises AuthRequiredError(CONNECTION_MISSING_SCOPES). Either is surfaced to the user; nothing falls through silently. Eager client_id_hash tripwire. Every load of a stored connection verifies that the OAuth client id under which it was issued matches the current configuration. A mismatch (e.g. after rotation) clears the stored entry and raises AuthRequiredError(REAUTH_REQUIRED). Users reconnect explicitly; we never use a stale connection. The daemon, the Agent UI and the CLI each cache the OAuth client, so when a stored connection doesn’t match that cache, the process re-reads the client config from the credential store before judging it. A connection another GAIA process just forwarded or reconnected under a new client is kept. A connection that still doesn’t match is cleared only while holding the connection lock. If another GAIA operation (in this process or another) holds it, the connection is left in place and the call raises ConnectionBusyError, a transient error rather than a disconnect: agent tools report it as busy, HTTP returns 503 with Retry-After, the connection listing keeps the row marked "error": "busy" (with no scopes, since they may be about to change), and the daemon keeps the token it already forwarded to a sidecar. An access token already forwarded to a sidecar stays usable until it expires or is withdrawn, even after the provider is disconnected. A forwarded connection saves its client config and its connection in one lock hold, and a forward that fails partway puts both back before letting go of the lock, so no process ever sees a new client next to an old connection.

What we do not protect

A malicious agent that the user has explicitly granted a scope can use that scope. This is by design — the user gave the agent the keys. The grants ledger and the AgentUI consent dialog exist so that “explicitly” is a high bar. Operators concerned about prompt-injection- driven scope escalation should review:
  • The agent’s REQUIRED_CONNECTORS declaration before granting (visible in the consent dialog).
  • The CLI grants list (gaia connectors grants list google) at any time.
  • Periodic revocation of unused grants (gaia connectors grants revoke <provider> <agent_id>).
A custom agent that ships its own agent.py can call get_access_token_sync directly. That call still goes through the two-layer authorization check. To bypass it the agent would need to forge an agent identity, which is why _agent_context is private (plan amendment A9). The grant-ledger key for custom agents is custom:<sha256-of-agent.py>:<id>, so a custom agent that changes its code gets a new key and cannot inherit a previous grant. An attacker with read access to the user’s home directory can read ~/.gaia/connectors/grants.json. Grants are per-agent scope declarations only — they do NOT contain tokens. Tokens are in the OS keychain, which is encrypted by the OS (Keychain on macOS, DPAPI on Windows, SecretService on Linux). The grants file is mode 0600 and the parent directory 0700 on POSIX systems. An attacker with read access to the OS keychain. Refresh tokens are visible to a process with the user’s keychain access (e.g. malware running as the user). This is the limit of OS-level credential storage and is the same posture as every other app that stores OAuth tokens locally (browsers, native mail clients, etc.). Concurrent processes refreshing the same provider’s token. Two GAIA processes running as the same user (e.g. gaia chat --ui and gaia connectors status) each maintain their own in-memory access- token cache and share the keyring. A refresh, a connect and a disconnect each hold a per-provider file lock (~/.gaia/connectors/<provider>.refresh.lock, per user like the keyring). A refresh re-reads the stored refresh token after taking it, so two GAIA processes of this version or later never refresh with the same token. Two writes happen outside the lock, both on reading a connection: the read clears it when the stored client ID or tenant no longer matches, or when the stored blob isn’t valid JSON. A refresh that finishes after the user reconnected or disconnected leaves the stored connection alone: invalid_grant deletes it, and a rotated token replaces it, only if it still holds the refresh token that was sent.

Threats considered

Operator checklist

  • GAIA_GOOGLE_CLIENT_ID is set in every environment that runs GAIA.
  • OS credential store is configured (built-in on macOS/Windows; gnome-keyring or kwallet on Linux).
  • Production users do not see keyrings.alt plaintext fallback — if they do, the connections module raises ConnectorsError with this page in the message.
  • Periodically review ~/.gaia/connectors/grants.json for unexpected grants. The CLI command gaia connectors grants list google enumerates them.
  • Privacy policy is published and linked from the OAuth consent screen (required for Google verification).
  • Sensitive-scope verification is submitted for the production client id (4–6 wk timeline).

See also