You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[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
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:
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.
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.
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:
Least privilege on the untrusted-input connector — strongest.
The user can start read-only and elevate later — the capability that is simply missing today.
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.
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/connectorsgrantedScopes.
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.
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_scopesrecords what Google actually granted, not what was asked (routes/email.ts:244, migration0133).scopesAllowSend/scopesAllowModify(lib/gmail.ts:641,:694) gate per stored grant, fail-closed.canSendis 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.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.tsxrenders 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:It does not, because
gmail.modifyis 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:244and
include_granted_scopesappears zero times in the repo (git grep -c include_granted_scopes→ no matches). Google's documented behaviour without it is that the token response'sscopenames 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:458says it plainly: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:257has onescopes?: string[]; it should be:For Gmail: baseline
openid email gmail.readonly; optionalgmail.send,gmail.modify.Then:
GET /v1/email/google/start?grant=send(or genericGET /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.include_granted_scopes=trueon every Google authorize URL, and merge rather than overwritegranted_scopeson the conflict update — so an elevate adds without a round trip that could subtract.missingScopesForreports 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.modifystops being "missing" and becomes "not yet allowed".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/modifyonly 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:
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.readonlyalone, orreadonly + 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 + sendand optional =modifyis still strictly better than today.Alternatives considered and rejected
include_granted_scopesis what Google provides for this.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.Acceptance criteria
gmail.readonlyonly, by an action PAGS offers, without unticking anything at Google.GET /v1/connectorsgrantedScopes.grantedScopesstill contains send.missingScopesand does not produce a "reconnect" note.Regression risk
include_granted_scopesor the merge is wrong, an elevate that requests onlygmail.modifycould come back with a grant string lackinggmail.send, andscopesAllowSendfail-closes — a working sending agent silently stops. A test on the merge (old ∪ new, never new alone) is the one that catches it.accountConnections.test.tsandconnectors-oauth.test.tsboth assert onmissingScopesForoutput 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.