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

Closed

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.

Activity

  1. added
    connectorsConnector + tool framework
    securitySecurity hardening / audit finding
    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guards
    on Aug 21, 2026
  2. serge-ivo commented on Sep 26, 2026

    @serge-ivo
    ContributorAuthor

    Resolved in b98a708. It follows the design in this issue, with baseline = gmail.readonly alone, the option this issue recommended.

    Grant layer (API)

    • The manifest has two lists now: scopes is what a plain connect asks for; optionalGrants ({id, label, scopes}) are asked for only when chosen.
      • Gmail's baseline is openid email gmail.readonly.
      • The optional grants are send ("Send and reply as you", gmail.send) and modify ("Archive and mark read", gmail.modify).
      • mail.google.com is still never requested.
    • GET /v1/email/google/start?grant=send&grant=modify (or grant=send,modify) adds the chosen powers. It's validated against the manifest: an undeclared grant is a 400 and is never passed to Google. ?account= sets login_hint.
    • include_granted_scopes=true is on every authorize URL.
    • The callback merges granted_scopes with what the account already held instead of overwriting it. An elevate for modify that comes back without send, or a plain read-only reconnect, can no longer narrow a grant. This is the "sharp edge" from Regression risk, and two tests pin it; both fail with the merge removed.
    • missingScopesFor diffs the baseline only. GET /v1/connectors reports optionalGrants: [{id, label, held}] for the connector and for each account.
    • The send and manage refusals name an action that exists: "use "Allow: Send and reply as you" on Gmail in Preferences → Connections — what it can already do is kept", and likewise for "Archive and mark read". They're built from the same labels the console renders.

    Consent UI (console, AccountConnections)

    • Choosing at connect: under Connect Gmail, "Connects read-only. Also let agents:" with one unticked checkbox per optional power. With neither ticked, Google is asked for reading only.
    • Allowing later: a connected account shows "read-only" or "X not allowed", plus an "Allow: …" button per power it lacks. With several accounts the buttons sit on each account's own line, with that mailbox pre-selected.
    • Reconnecting: "Add or reconnect" now says it never takes away what was allowed.
    • Pre-recording grants: an old grant from before scopes were recorded is offered both powers instead of being called stale.
    • No connector is named in the component; it's driven by the catalog's optionalGrants.

    Docs:

    • platform-docs/connectors.md: the false "decline it at Google's screen" sentence is replaced with the choose-at-connect / Allow-later model, the merge, and how to narrow (disconnect and reconnect with fewer boxes, or revoke at myaccount.google.com/permissions).
    • openapi.yaml: documents grant and account.

    Acceptance criteria

    • Connect Gmail holding gmail.readonly only, by an action PAGS offers: met. A plain connect is read-only; there's nothing to untick at Google.
    • A named affordance to allow sending and managing later, resulting in a grant that has both: met. "Allow: …" per power, and grantedScopes / optionalGrants.held reflect it. Verified by the merge tests, not against a live Google consent.
    • Elevating never narrows an existing grant: met. Connect with send, then elevate for modify: send is kept. Tested, and fails without the merge.
    • An ungranted optional scope is not in missingScopes and gives no "reconnect" note: met. Tested in both the API and the console.
    • Every Gmail tool refusal names an action that exists in the console: met, and tested.

    Tests

    Not done here, and why:

    • Merge caveat: if an owner revokes the app at Google, the merged record can still claim a power the new token lacks. The send then fails at Google instead of being refused in advance.
    • Nothing was measured against a live Google consent: the include_granted_scopes behaviour relies on Google's documentation, as this issue itself noted. The merge is what keeps this safe either way.
    • Existing connections keep what they were granted. On their next plain reconnect the merge keeps send and modify, so nobody silently loses sending.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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