Skip to content

Let an agent select scopes from a connection's menu - #37

Merged
haruotsu merged 7 commits into
mainfrom
google-scope-menu
Aug 27, 2026
Merged

Let an agent select scopes from a connection's menu#37
haruotsu merged 7 commits into
mainfrom
google-scope-menu

Conversation

@haruotsu

@haruotsu haruotsu commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Why

The google connection listed sheets, drive, and people among its hosts, but its scopes were gmail.readonly and calendar.readonly only — 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:

  • the connection carries a reviewed menu (oauth.optional_scopes) next to its defaults (oauth.scopes),
  • the agent selects from that menu in its own declaration, and the selection is built into that agent's authorization URI alone.

Invariants kept

  1. A bare connections: [google] keeps meaning what it always did: the read-only gmail + calendar defaults.
  2. Write scopes are opt-in only. A selection lands in the selecting agent's authorization and nowhere else.
  3. An agent can select only from the menu (catalog, or its gete.yaml override). gete validate refuses anything else, and gete register refuses it again since it can run without validate. The same connection declared twice — the string and the mapping spelling name one connection, which uniqueItems cannot see — is refused by both for the same reason: only one entry's selection could reach the consent screen.
  4. hosts remains the ceiling for where a token may be sent, whatever scopes it carries, and none of it reaches GCP: www.googleapis.com appears only scoped to the paths of the APIs served nowhere else.

What changed

Declaration — a connections entry may now be a mapping:

connections:
  - freee                       # the defaults only, as before
  - id: google                  # the defaults plus a selection from the menu
    scopes: [https://www.googleapis.com/auth/spreadsheets]

Hosts — an entry may scope itself to a path prefix, written host/path/. 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 — and a bare www.googleapis.com would open storage, compute, and oauth2 as well. The google hosts therefore carry www.googleapis.com/calendar/, www.googleapis.com/drive/, and www.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 the calendar.readonly default 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 is drive.file only (files the agent creates or the user opens with it); Drive-wide write is deliberately absent. 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 — and 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: rewriting or deleting the inbox is not needed to send. docs.googleapis.com and slides.googleapis.com join the hosts, and people.googleapis.com — until now a host no scope could use — gets contacts.readonly on 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 as scopes; a hosts entry may be host/path/ (trailing slash required, so /api cannot quietly admit /api-and-more).
  • connection/registry.py: allows() and allows_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 verbatim authorization_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 verbatim authorization_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.
  • Conformance tests pin the google invariants: read-only defaults, the exact menu, no blanket Drive write, no inbox mutation, consent text for gmail.send that does not soften what it grants, www.googleapis.com never bare, and storage, compute, and oauth2 refused by allows().

Not included

The optional warning for agents that select a write scope while declaring no effect: write tool was left out: validate currently has no warning channel, only problems, and a hard error there would be wrong. Worth revisiting if a warning level ever exists.

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
@haruotsu
haruotsu merged commit 3c2ed5d into main Aug 27, 2026
3 checks passed
@haruotsu
haruotsu deleted the google-scope-menu branch August 27, 2026 13:47
@github-actions github-actions Bot mentioned this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant