Let an agent select scopes from a connection's menu - #37
Merged
Conversation
Authorizations are created per agent, but their scopes were composed from the connection definition alone: every agent holding a connection asked for everything the definition listed. Widening a catalog entry would have raised every holder's token to the new maximum at their next consent, so the catalog could only ever carry the least common denominator. A connection now offers a menu next to its defaults. oauth.scopes stays what every agent gets; oauth.optional_scopes is what an agent may add by selecting it in its own declaration, and the selection is built into that agent's authorization URI alone, so one agent's opt-in grants nothing to any other. validate refuses a selection from outside the menu, and register refuses it again because it can be run without validate. A menu next to a verbatim authorization_query is refused at the definition: the query is used as written, so no selection could ever reach the consent screen, and dropping it silently would grant less than the agent declared. 🤖 Generated with Claude Code
The google connection listed sheets, drive, and people among its hosts while its scopes could read none of them: a token could travel to the Sheets API and only ever be refused there. With scopes now selected per agent, the catalog can carry the rest of Workspace without touching what a bare `connections: [google]` means - the defaults stay the read-only gmail and calendar pair, and everything wider is on the menu, reaching only the agents that declare it. The menu offers reads for Sheets, Drive, Docs, and Slides, and writes for Sheets, Docs, Slides, and calendar events. Drive write is drive.file only - the files the agent creates or the user opens with it - because Drive-wide write would put every file the user can touch behind one consent. gmail.send is the heaviest entry: mail sent as the user is outward and cannot be recalled, which is what the old read-only comment feared. It is offered because it stays opt-in and its consent text says plainly that the agent sends as the user; gmail.modify and mail.google.com stay off the menu because rewriting or deleting the inbox is not needed to send. docs.googleapis.com and slides.googleapis.com join the hosts as the endpoints those scopes are used against, keeping one host per API so a Workspace authorization still cannot reach GCP. 🤖 Generated with Claude Code
Some platforms serve unrelated APIs from a single host: www.googleapis.com carries Drive and Calendar next to GCP's storage, compute, and oauth2, so a connection could not name it without opening the whole platform to its tokens. An entry written as host/path/ now admits only requests below that path. A dot segment or an encoded separator - percent-encoded any number of times - is refused rather than resolved, because resolving would have to guess how many times the server decodes. The schema requires the trailing slash so /api cannot quietly admit /api-and-more. 🤖 Generated with Claude Code
Drive v3 and Calendar v3 are served from www.googleapis.com and nowhere else: drive.googleapis.com and calendar-json.googleapis.com are service names that route no requests, yet they sat on the host list looking like endpoints while every Drive and Calendar call was refused by the ceiling. Replace them with www.googleapis.com/calendar/, /drive/, and /upload/drive/ so the calendar.readonly default and the Drive and Calendar menu entries actually work, and give people.googleapis.com - until now a host no scope could use - contacts.readonly on the menu. Conformance pins the exact menu, that www.googleapis.com appears only path-scoped, and that storage, compute, and oauth2 stay refused. 🤖 Generated with Claude Code
uniqueItems cannot see that a string entry and a mapping entry name the same connection, and scope selections are keyed by id, so one entry's selection would silently stand for every duplicate on the consent screen. validate already reports the duplicate, but register can run without validate, so it now refuses the duplicate too rather than registering an authorization the declaration does not state unambiguously. validate also reports the duplicate itself when the connection is unknown, instead of two unknown-connection errors. 🤖 Generated with Claude Code
Three ways a declaration could promise path scoping that allows() would not deliver, each now caught where the declaration is checked: A bare hosts entry admits every path on its host, so a scoped entry for the same host never applies - it reads as a restriction it does not make. base_url puts its host on the list bare, so setting one on a scoped host widened the ceiling the same way, silently. Both are reported as connection problems. mcp.url was matched by hostname membership, which cannot see a path-scoped entry: a URL below the path was wrongly refused, and one beside the path on the same host would need a bare entry to pass. The check now asks allows(), the same question the token answers to. The hosts schema admitted "." and ".." segments in a host/path/ entry. allows() refuses every request that carries a dot segment, so such an entry could never match anything; refusing it at declaration time beats shipping a silently dead entry. 🤖 Generated with Claude Code
CI checks formatting with ruff format --check, which local verification had skipped; the message fits on one line, so format folds it there. 🤖 Generated with Claude Code
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The
googleconnection listedsheets,drive, andpeopleamong its hosts, but its scopes weregmail.readonlyandcalendar.readonlyonly — a token could travel to the Sheets API and be refused there every time. The catalog could not simply grow, though: authorizations are created per agent, but scopes were composed from the connection definition alone, so adding a write scope to the catalog would have put it on the consent screen — and into the token — of every agent holding the connection.This PR splits that into two layers:
oauth.optional_scopes) next to its defaults (oauth.scopes),Invariants kept
connections: [google]keeps meaning what it always did: the read-only gmail + calendar defaults.gete.yamloverride).gete validaterefuses anything else, andgete registerrefuses it again since it can run without validate. The same connection declared twice — the string and the mapping spelling name one connection, whichuniqueItemscannot see — is refused by both for the same reason: only one entry's selection could reach the consent screen.hostsremains the ceiling for where a token may be sent, whatever scopes it carries, and none of it reaches GCP:www.googleapis.comappears only scoped to the paths of the APIs served nowhere else.What changed
Declaration — a
connectionsentry may now be a mapping:Hosts — an entry may scope itself to a path prefix, written
host/path/. Drive v3 and Calendar v3 are served fromwww.googleapis.comand nowhere else —drive.googleapis.comandcalendar-json.googleapis.comare service names that route no requests — and a barewww.googleapis.comwould open storage, compute, and oauth2 as well. The google hosts therefore carrywww.googleapis.com/calendar/,www.googleapis.com/drive/, andwww.googleapis.com/upload/drive/next to the per-API hostnames. A dot segment or an encoded separator in a request path is refused rather than resolved, because resolving would have to guess how many times the server decodes. This is also what makes thecalendar.readonlydefault actually reachable.Catalog (
google.yaml) — the menu offers reads for Sheets, Drive, Docs, Slides, and contacts, and writes for Sheets, Docs, Slides, and calendar events. Drive write isdrive.fileonly (files the agent creates or the user opens with it); Drive-wide write is deliberately absent.gmail.sendis the heaviest entry — mail sent as the user is outward and cannot be recalled, which is what the old read-only comment feared — and is offered because it stays opt-in and its consent text says plainly that the agent sends as the user.gmail.modifyandmail.google.comstay off the menu: rewriting or deleting the inbox is not needed to send.docs.googleapis.comandslides.googleapis.comjoin the hosts, andpeople.googleapis.com— until now a host no scope could use — getscontacts.readonlyon the menu. All serving hosts were verified against the live endpoints.Mechanism
schema/agent.json: a connections entry is a string or{id, scopes}; an entry that selects nothing must be written as the string.schema/connection.json+OAuth:optional_scopes, same scope→consent-text shape asscopes; ahostsentry may behost/path/(trailing slash required, so/apicannot quietly admit/api-and-more).connection/registry.py:allows()andallows_redirect()understand path-scoped host entries.register.py: the authorization URI carries defaults + that agent's selection; a selection outside the menu, a selection on a connection with a verbatimauthorization_query, or a connection declared twice in both spellings is refused rather than silently dropped.validate.py: selection ⊆ menu (the problem names what the menu offers), and a connection declared in both spellings is reported — even when the connection itself is unknown to the registry.connection/checks.py: a menu entry repeated in the defaults, or a menu next to a verbatimauthorization_query, is a definition problem.gete connections <id>prints the menu next to the defaults, since the OAuth client has to be prepared for everything an agent may select.gmail.sendthat does not soften what it grants,www.googleapis.comnever bare, and storage, compute, and oauth2 refused byallows().Not included
The optional warning for agents that select a write scope while declaring no
effect: writetool was left out:validatecurrently has no warning channel, only problems, and a hard error there would be wrong. Worth revisiting if a warning level ever exists.