Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,28 @@ connections:
scopes: {read: Read internal data}
```

A `hosts` entry is an exact host name; nothing is matched by suffix. When one
host serves unrelated APIs side by side — `www.googleapis.com` carries Drive
and Calendar next to GCP's storage and compute — the entry can be scoped to a
path prefix, written `host/path/`, and requests must stay below that path.
A bare entry admits every path on its host, so declaring the same host bare
next to a scoped entry — or setting `base_url` on that host, which lists it
bare — is reported: the scoping would silently not happen.

A connection's `oauth.scopes` go to every agent that declares it, so they stay
a read-only minimum. Scopes under `oauth.optional_scopes` are a menu: an agent
gets one only by selecting it in its own declaration, and the selection lands
in that agent's own authorization, so consenting to one agent's writes grants
nothing to any other. A scope outside the menu is refused by `gete validate`.

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

`oauth.pkce: true` asks Gemini Enterprise to carry a code challenge through
the flow. An authorization server that requires PKCE refuses the code exchange
without one, and there is no other way to ask for it from a declaration.
Expand Down
44 changes: 37 additions & 7 deletions src/gete/catalog/connections/google.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,56 @@ id: google
display_name: Google Workspace
docs: https://developers.google.com/workspace

# One host per API. Listing googleapis.com as a whole would let a Workspace
# authorization reach GCP APIs, and so would www.googleapis.com, which serves
# storage, compute, and oauth2 next to the Workspace APIs.
# The ceiling: every host a token may be sent to, whatever scopes it carries.
# One entry per API. Listing googleapis.com as a whole would let a Workspace
# authorization reach GCP APIs, and so would a bare www.googleapis.com, which
# serves storage, compute, and oauth2 next to Drive and Calendar. Those two
# are served nowhere else - their service names drive.googleapis.com and
# calendar-json.googleapis.com route no requests - so their entries scope
# www.googleapis.com to the APIs' own paths, Drive uploads included.
hosts:
- gmail.googleapis.com
- calendar-json.googleapis.com
- sheets.googleapis.com
- drive.googleapis.com
- docs.googleapis.com
- slides.googleapis.com
- people.googleapis.com
- www.googleapis.com/calendar/
- www.googleapis.com/drive/
- www.googleapis.com/upload/drive/
token_prefixes:
- ya29.

oauth:
authorization_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
# Read-only. Asking for write access would let an agent send or approve
# on the user's behalf, and gete agents are not meant to.
# The floor: what every agent that declares the connection gets. Read-only
# and minimal, so a bare `connections: [google]` keeps meaning what it
# always meant.
scopes:
https://www.googleapis.com/auth/gmail.readonly: Read the subject, sender, and body of your mail
https://www.googleapis.com/auth/calendar.readonly: Read your calendar events
# The menu: an agent gets one of these only by selecting it in its own
# declaration, and the selection lands in that agent's authorization alone.
# Write access lives here and never in the defaults, so no agent can write
# without saying so on its consent screen.
#
# drive.file reaches only the files the agent creates or the user opens
# with it; Drive-wide write is deliberately not offered. gmail.send is the
# heaviest entry - mail sent as the user is outward and cannot be recalled -
# and it grants sending alone: gmail.modify and mail.google.com are not
# offered, because rewriting or deleting the inbox is not needed to send.
optional_scopes:
https://www.googleapis.com/auth/spreadsheets.readonly: Read your spreadsheets
https://www.googleapis.com/auth/drive.readonly: Read your Drive files
https://www.googleapis.com/auth/documents.readonly: Read your documents
https://www.googleapis.com/auth/presentations.readonly: Read your presentations
https://www.googleapis.com/auth/contacts.readonly: Read your contacts
https://www.googleapis.com/auth/spreadsheets: Read and edit your spreadsheets
https://www.googleapis.com/auth/documents: Read and edit your documents
https://www.googleapis.com/auth/presentations: Read and edit your presentations
https://www.googleapis.com/auth/calendar.events: Read and edit events on your calendars
https://www.googleapis.com/auth/drive.file: Read and edit the Drive files this agent creates or opens
https://www.googleapis.com/auth/gmail.send: Send mail as you

examples:
accepts:
Expand Down
41 changes: 37 additions & 4 deletions src/gete/connection/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str]
problems.append(
f"hosts: {host} is a whole platform domain, list the API host"
)
for entry in sorted(connection.hosts):
# A bare 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 silently widens the ceiling the same way.
host, slash, _ = entry.partition("/")
if not slash or host not in connection.hosts:
continue
if connection.base_url and urlsplit(connection.base_url).hostname == host:
problems.append(
f"hosts: {entry} never applies; base_url puts {host} on the "
"list bare, and a bare entry admits every path"
)
else:
problems.append(
f"hosts: {entry} never applies; the bare {host} entry admits every path"
)
for other in registry.all(include_retired=True):
if other.id == connection.id:
continue
Expand All @@ -85,6 +102,18 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str]
problems.append(
f"token_prefixes: {prefix!r} overlaps {theirs!r} ({other.id})"
)
for scope in sorted(connection.oauth.optional_scopes):
if scope in connection.oauth.scopes:
problems.append(
f"oauth.optional_scopes: {scope} is already a default scope"
)
if connection.oauth.optional_scopes and connection.oauth.authorization_query:
# The verbatim query is the whole authorization URL; a selection
# would be accepted and then never reach the consent screen.
problems.append(
"oauth.optional_scopes: the menu cannot be offered next to a "
"verbatim authorization_query, which fixes the scopes"
)
for token in connection.examples.accepts:
if not connection.accepts_token(token):
problems.append(f"examples.accepts: {token!r} is not accepted")
Expand All @@ -99,8 +128,12 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str]
)
if not claims_google and connection.accepts_token(_GOOGLE_ACCESS_TOKEN_EXAMPLE):
problems.append("a Google access token is accepted")
if connection.mcp_url is not None and not connection.needs_base_url:
mcp_host = urlsplit(connection.mcp_url).hostname
if mcp_host not in connection.hosts:
problems.append(f"mcp.url: host {mcp_host} is not in hosts")
# The MCP server is spoken to with the user's token, so its URL must sit
# where hosts lets the token go - path scoping included.
if (
connection.mcp_url is not None
and not connection.needs_base_url
and not connection.allows(connection.mcp_url)
):
problems.append(f"mcp.url: {connection.mcp_url} is not covered by hosts")
return problems
53 changes: 44 additions & 9 deletions src/gete/connection/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass, field, replace
from typing import Any
from urllib.parse import urlsplit
from urllib.parse import unquote, urlsplit

from gete.catalog import catalog_connections
from gete.errors import DeclarationError, RetiredConnection, UnknownConnection
Expand Down Expand Up @@ -66,7 +66,10 @@ def looks_like_jwt(token: str) -> bool:
class OAuth:
"""How users authorize the connection.

scopes maps a scope to the explanation shown on the consent screen.
scopes maps a scope to the explanation shown on the consent screen; every
agent that declares the connection gets them. optional_scopes is the menu
an agent may select from on top of that, in the same shape; none of them
reach a token unless the agent declares them.
scope_parameter is the query parameter that carries the user scopes; Slack
uses user_scope because scope means the app's own permissions there.
authorization_query, when set, is used verbatim as the authorization URL's
Expand All @@ -80,6 +83,7 @@ class OAuth:
authorization_url: str
token_url: str
scopes: Mapping[str, str]
optional_scopes: Mapping[str, str] = field(default_factory=dict)
scope_parameter: str = "scope"
authorization_query: Mapping[str, str] | None = None
pkce: bool = False
Expand All @@ -92,6 +96,7 @@ def from_mapping(
authorization_url=str(_rooted(data["authorization_url"], base_url)),
token_url=str(_rooted(data["token_url"], base_url)),
scopes=dict(data["scopes"]),
optional_scopes=dict(data.get("optional_scopes", {})),
scope_parameter=data.get("scope_parameter", "scope"),
pkce=bool(data.get("pkce", False)),
authorization_query=(
Expand All @@ -102,6 +107,26 @@ def from_mapping(
)


def _stays_below(path: str, prefix: str) -> bool:
"""Whether the request path stays below the prefix however a server reads it.

A dot segment or an encoded separator - percent-encoded any number of
times - is refused rather than resolved: resolving would have to guess
how many times the server decodes.
"""
for segment in path.split("/"):
decoded = unquote(segment)
while decoded != segment:
segment, decoded = decoded, unquote(decoded)
if segment in (".", "..") or "/" in segment or "\\" in segment:
return False
if not prefix.endswith("/"):
# The schema requires the trailing slash; a definition that dodged it
# must not widen the ceiling to /prefix-and-more.
prefix += "/"
return path.startswith("/" + prefix)


@dataclass(frozen=True)
class Examples:
"""Token shapes, not real tokens. The catalog checks accepts_token against them."""
Expand Down Expand Up @@ -225,21 +250,31 @@ def allows(self, url: str) -> bool:
"""Whether a token may be attached to a request for this URL.

Only https, and only an exact host match. Prefix or suffix matching
would accept names such as slack.com.example.com.
would accept names such as slack.com.example.com. An entry written as
host/path/ admits only requests below that path: some platforms serve
unrelated APIs from one host, and the path is where they part.
"""
parsed = urlsplit(url)
return parsed.scheme == "https" and parsed.hostname in self.hosts
if parsed.scheme != "https" or parsed.hostname is None:
return False
for entry in self.hosts:
host, slash, prefix = entry.partition("/")
if parsed.hostname != host:
continue
if not slash or _stays_below(parsed.path, prefix):
return True
return False

def allows_redirect(self, url: str) -> bool:
"""Whether a download may follow a redirect here; the token may not.

Exact hostname matching against the declared lists only: a named
host is no safer than an address literal, so nothing is accepted
for merely looking like a public name.
Exact matching against the declared lists only: a named host is no
safer than an address literal, so nothing is accepted for merely
looking like a public name.
"""
parsed = urlsplit(url)
return parsed.scheme == "https" and (
parsed.hostname in self.hosts or parsed.hostname in self.redirect_hosts
return self.allows(url) or (
parsed.scheme == "https" and parsed.hostname in self.redirect_hosts
)

def reauthorization_message(self) -> str:
Expand Down
4 changes: 4 additions & 0 deletions src/gete/connections_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def format_connection(connection: Connection) -> str:
"""
oauth = connection.oauth
scopes = [f"{scope}: {text}" for scope, text in oauth.scopes.items()]
optional = [f"{scope}: {text}" for scope, text in oauth.optional_scopes.items()]
fields: list[tuple[str, list[str]]] = [
# The same word the listing uses; the reason gets a line of its own.
("status", ["retired" if connection.retired else "available"]),
Expand All @@ -63,6 +64,9 @@ def format_connection(connection: Connection) -> str:
("authorization", [oauth.authorization_url]),
("token url", [oauth.token_url]),
("scopes", scopes or [NONE_DECLARED]),
# The menu, because the OAuth client has to be prepared for every
# scope an agent may select, not only the defaults.
("optional scopes", optional or [NONE_DECLARED]),
("client id", [connection.client_id_secret]),
("client secret", [connection.client_secret_secret]),
("redirect uri", [REDIRECT_URI]),
Expand Down
20 changes: 19 additions & 1 deletion src/gete/declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,25 @@ def name(self) -> str:

@property
def connections(self) -> tuple[str, ...]:
return tuple(self.data.get("connections", ()))
"""Connection ids, whichever way each entry is written.

An entry is the id itself, or a mapping that also selects scopes from
the connection's menu; everything that only needs to know which
connections the agent holds reads the ids from here.
"""
return tuple(
entry if isinstance(entry, str) else str(entry["id"])
for entry in self.data.get("connections", ())
)

@property
def scope_selections(self) -> dict[str, tuple[str, ...]]:
"""Selected optional scopes by id; ids that select nothing are absent."""
return {
str(entry["id"]): tuple(entry["scopes"])
for entry in self.data.get("connections", ())
if not isinstance(entry, str)
}

@property
def tools(self) -> tuple[Mapping[str, Any], ...]:
Expand Down
Loading