Skip to content

[design] Gmail is granted all-or-nothing in practice: no read-only connect, no way to elevate later, and "decline it at Google's screen" is stated nowhere the user will see it #718

Description

@serge-ivo

The question, stated fairly

PAGS's current answer to "how does a user get less than full Gmail access" is: request the whole set in one authorize URL, let Google's consent screen be the granularity surface, and degrade gracefully when a scope comes back absent. That is a real design, it is implemented deliberately, and most of it works:

  • granted_scopes records what Google actually granted, not what was asked (routes/email.ts:244, migration 0133).
  • scopesAllowSend / scopesAllowModify (lib/gmail.ts:641, :694) gate per stored grant, fail-closed.
  • canSend is reported per account (GET /v1/email/status), and a shortfall is stated on the account row rather than discovered as a Google 403 mid-task.
  • Tool refusals name the state instead of leaking "insufficient authentication scopes".

This issue is not "that design is wrong". It is: the design assumes a granularity surface that PAGS does not actually put in front of the user, and it has no path back once a choice is made.

Three gaps, in order of how much they cost

1. Nothing in PAGS tells the user the choice exists

Grep the console for any mention of declining a scope: there is none. store/console/src/components/AccountConnections.tsx renders one button, "Add or reconnect" (:252), and one explanatory line — "Choose the account at {label} — picking the same one refreshes it, picking a different one adds it alongside" (:236). Account choice is explained; permission choice is not mentioned anywhere.

The only place the platform says a user may decline is the published doc, and there it is currently false — platform-docs/connectors.md:418:

"Google's consent screen lets a person grant reading and sending but decline manage-mail"

It does not, because gmail.modify is never in the authorize URL (#717).

Combined with prompt=consent (routes/email.ts:70) and a bundled request, grant-everything is the path of least resistance and the only path anyone is told about.

2. A user who declines cannot change their mind, and the record of what they granted decays

There is no "allow sending now" action anywhere. The only control is Add or reconnect, which re-runs the identical bundled request.

Worse, a narrower re-consent overwrites the record:

routes/email.ts:244

granted_scopes = excluded.granted_scopes,

and include_granted_scopes appears zero times in the repo (git grep -c include_granted_scopes → no matches). Google's documented behaviour without it is that the token response's scope names what this authorization requested, not the union of everything the user has ever granted the client. So a user who reconnects and unticks a box has their stored grant string narrowed to that request — which is correct for this token, but means the stored record tracks the last consent rather than the account's standing authorization.

(Inferred from Google's documented incremental-authorization behaviour, not measured — testing it requires completing a real consent, which is out of scope for a read-only pass. Worth measuring before building on it.)

3. Blast radius, on the connector whose input is untrusted by construction

Gmail is the one connector whose content is written by strangers. lib/gmail.ts:458 says it plainly:

"Everything above reads. This half writes, and it is the most consequential thing an agent on this platform can do: mail leaves under the owner's own name, to a real person, and cannot be recalled."

A power described that way is currently in the default bundle for every user who clicks Connect, whether or not any agent they own can send. The existing gates (per-agent email permission, capabilities.tools, per-instance write consent #90) are all downstream of the grant — they constrain what an agent may do with a token the platform already holds. Least privilege at the grant layer is the one layer not applied.

What I would build

Split the manifest's scope list into baseline and optional. lib/connectors/types.ts:257 has one scopes?: string[]; it should be:

oauth: {
  scopes: string[];          // requested at connect — the minimum the connector is useful with
  optionalScopes?: string[]; // requested ONLY by an explicit elevate
}

For Gmail: baseline openid email gmail.readonly; optional gmail.send, gmail.modify.

Then:

  1. An elevate endpoint. GET /v1/email/google/start?grant=send (or generic GET /v1/connectors/:id/oauth/start?grant=…) builds the authorize URL as baseline + the named optional scopes, validated against the manifest so a caller cannot request a scope the connector does not declare.
  2. include_granted_scopes=true on every Google authorize URL, and merge rather than overwrite granted_scopes on the conflict update — so an elevate adds without a round trip that could subtract.
  3. missingScopesFor reports baseline gaps only. An absent optional scope is not a shortfall; it is a state with an offer attached. This is also the structural fix for gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717's false "read-only" line: gmail.modify stops being "missing" and becomes "not yet allowed".
  4. The refusal carries the elevate, not a reconnect. RECONNECT_TO_MODIFY / RECONNECT_TO_SEND (lib/connectors/gmail.ts:241, :246) should name an action that exists — see [bug] A refused tool's own remedy is discarded and the agent invents "Settings → Connections" — reproduced 5/5, and the transcript truncates the real remedy away #517 for what happens to a refusal whose remedy the model has to invent.

On the verification-cost argument — I think it is weaker than it looks

The case has been made that requesting send/modify only from users who elevate gives PAGS a materially better story for Google's restricted-scope verification and the annual CASA assessment.

I do not think that follows. Google verifies the OAuth client's declared scope set, configured on the consent screen, not the distribution of per-user grants. A scope that is declared and requested by 1% of users is assessed exactly like one requested by 100%. Incremental authorization therefore does not reduce the CASA surface; only not declaring the scope on the client at all does.

So the honest ranking of arguments for this change is:

  1. Least privilege on the untrusted-input connector — strongest.
  2. The user can start read-only and elevate later — the capability that is simply missing today.
  3. Verification cost — no effect, unless the decision is to drop a scope entirely, which is a different decision (and is one of gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717's two options).

I would rather the ticket be built on (1) and (2) than on an argument that will not survive contact with Google's actual verification process.

Open question for the owner — I would go read-only

Is the connect-time baseline gmail.readonly alone, or readonly + send?

I would make it readonly alone, on the strength of the codebase's own sentence at lib/gmail.ts:458. The cost is one extra consent round trip for anyone who wants a sending agent, at the moment they first want one — which is also the moment the request is self-explanatory, unlike at connect time when nothing on screen says what sending is for.

The counter-argument is real and the owner should weigh it: Email Assistant and Inbox Chat both promise replying, so a read-only baseline makes their first useful action a detour through Google. If that is judged too costly, baseline = readonly + send and optional = modify is still strictly better than today.

Alternatives considered and rejected

  • Keep the bundled request and just add copy to the console ("you can untick sending at Google"). Rejected as sufficient: it makes declining discoverable but still irreversible-by-half — there is no way to add a scope back, so the honest copy would have to say "and you cannot change this without reconnecting from scratch". The offer and the reversal have to ship together.
  • A second Google OAuth client for the read-only variant. Rejected: two clients means two consent-screen configurations, two verification submissions, two sets of rows keyed by the same provider id, and a user who connects "read-only Gmail" and "full Gmail" as separate accounts of the same mailbox. include_granted_scopes is what Google provides for this.
  • Do it at the vault layer — store the broad token, refuse narrowly. Rejected: that is what today's scopesAllow* checks already are, and it is exactly the layer that cannot help if the token itself leaks or if a bug bypasses the check. The point of the grant layer is that the capability is not held at all.
  • Fold this into [design] Stage 2: register the generic connector OAuth redirect for Drive/WorkDrive/Gmail and retire the dedicated flows (Stage 1 — the registry declaration — has shipped) #352 Stage 2 (retire the dedicated flows). Rejected as a dependency: Stage 2 needs a Google-console redirect registration and has no owner. The baseline/optional split is orthogonal and lands in either arrangement.

Acceptance criteria

  • A user can connect Gmail and end up holding gmail.readonly only, by an action PAGS offers, without unticking anything at Google.
  • A user in that state sees a named affordance to allow sending (and managing) later, and taking it results in a stored grant that has both — verified against GET /v1/connectors grantedScopes.
  • Elevating never narrows an existing grant: connect with send, elevate for modify, and grantedScopes still contains send.
  • An optional scope that has not been granted does not appear in missingScopes and does not produce a "reconnect" note.
  • Every Gmail tool refusal names an action that exists in the console at the time it is emitted.

Regression risk

  • The narrowing hazard is the sharp edge: if include_granted_scopes or the merge is wrong, an elevate that requests only gmail.modify could come back with a grant string lacking gmail.send, and scopesAllowSend fail-closes — a working sending agent silently stops. A test on the merge (old ∪ new, never new alone) is the one that catches it.
  • Changing the connect baseline changes what existing users see on their next reconnect. Anyone who reconnects after this ships must not silently lose send. The merge is what prevents it; without it, this change actively breaks people.
  • accountConnections.test.ts and connectors-oauth.test.ts both assert on missingScopesFor output shape; narrowing it to baseline-only will move those expectations.

Scope note

The console panel for all of this — how the elevate is presented, per-tool visibility, per-send approval — is being filed separately by another pass. This issue is deliberately the grant layer: what is in the authorize URL, what is stored, and what the API reports. The two need to land together to be worth anything, but they are different files.

Related: #717 (the live bug this design also fixes structurally), #714, #713, #715, #352, #517, #90.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsconnectorsConnector + tool frameworksecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions