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. Thegaia.connectors.store module writes to:
- macOS Keychain (built-in)
- Windows Credential Locker (built-in)
- Linux SecretService (gnome-keyring or kwallet)
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:
- The named agent must have a per-agent grant covering the requested
scopes (in
~/.gaia/connectors/grants.json). - The stored OAuth connection must actually carry those scopes (the user authorized them).
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_CONNECTORSdeclaration 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>).
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_IDis set in every environment that runs GAIA. - OS credential store is configured (built-in on macOS/Windows;
gnome-keyringorkwalleton Linux). - Production users do not see
keyrings.altplaintext fallback — if they do, the connections module raisesConnectorsErrorwith this page in the message. - Periodically review
~/.gaia/connectors/grants.jsonfor unexpected grants. The CLI commandgaia connectors grants list googleenumerates 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
- Connections security model — credential storage, the grant + activation model, and revocation across every connector type.
- Connectors overview
- Google OAuth Client runbook