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
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ tools:
### Connections

`gete connections` lists what ships: `freee`, `freee-mcp`, `google`, `github`,
`notion-mcp`, `slack-mcp`, and `zendesk`. Add your own or override a catalog
`github-app`, `notion-mcp`, `slack-mcp`, and `zendesk`. Add your own or override a catalog
entry in `gete.yaml`:

```yaml
Expand Down Expand Up @@ -422,6 +422,76 @@ connections:
Adding a connection to the catalog is one YAML file under
`src/gete/catalog/connections/`; the conformance tests check it.

### Connections gete issues tokens for

Some reads have no user's token behind them either: the agent is meant to
read the same repositories whoever calls it. `github-app` is a connection whose tokens gete issues itself, from
a GitHub App's private key, instead of receiving them from Gemini Enterprise.
Agents use it from `openapi` blocks (and python tools through gete's client)
exactly like any other connection — `operations`, `params`, `does_not`, and
the host check all apply as before:

```yaml
# gete.yaml
connections:
github-app:
base_url: https://ghe.example.com/api/v3 # leave out for github.com
app:
app_id: 123
private_key_secret: ge-github-app-private-key
# The ceiling of every token issued through this connection
repositories: [example-org/requests]
permissions: {issues: read}

# agent.yaml
connections: [github-app]
tools:
- openapi:
spec: ./specs/github.yaml
connection: github-app
effect: read
operations: [SearchIssues, GetIssue, ListIssueComments]
params:
SearchIssues:
q: {prefix: "repo:example-org/requests is:issue "}
```

For such a connection gete:

- signs an RS256 App JWT from `app_id` and the key, backdated a minute and
valid for less than ten, and finds the installation through the first of
`repositories` the App is installed on
(`GET /repos/{owner}/{repo}/installation`);
- asks for an installation token narrowed to `repositories` and
`permissions`, and reuses it in the process until a few minutes before
its `expires_at`;
- creates no Gemini Enterprise authorization and offers no reauthorization
tool. A missing key, or GitHub refusing to issue a token, is reported to
the user as text;
- delivers the key like `secret_env`: `private_key_secret` reaches the
deployment as `GETE_APP_KEY_GITHUB_APP`, which the agent cannot set
itself. The App ID and the ceiling travel in the resolved declaration.
`gete run` reads the PEM from the same variable. When the agent is built,
before its own modules are imported, gete takes the variable out of the
environment and keeps the key to itself;
- draws the connection in `gete graph` marked `(bot)`, like a shared
credential.

`repositories` must share one owner, since a token comes from one
installation. `permissions` is required: left out, a token would carry
everything the installation was granted. Whoever can call the agent acts as
the App within that ceiling, whatever they could reach on GitHub
themselves, so keep it to what the agent's tools read. `mcp` blocks cannot
use an app connection yet.

The ceiling binds the tokens gete issues, not the key. Python tools run in
the same process as gete, and code that goes looking for the key there can
find it and issue a token with the installation's whole grant. Taking it out
of the environment keeps it away from tools reading their settings and from
processes they start; it is not a sandbox. Grant the App itself no more than
the agents holding the connection may do, and review the python tools of
those agents as code that holds the key.

### Shared credentials

A connection reads with the caller's token. Some writes have no such token
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ dependencies = [
# The mcp extra is what makes mcp: tools work; without it the toolset
# module fails to import where the agent is deployed.
"google-adk[mcp]>=2.6,<2.9",
# Signs a GitHub App's JWT for the app connections. ADK brings it in
# already; it is named because gete imports it itself.
"cryptography>=43",
"httpx>=0.28",
"jsonschema>=4.23",
"pyyaml>=6.0",
Expand Down
42 changes: 42 additions & 0 deletions src/gete/catalog/connections/github-app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
id: github-app
display_name: GitHub App
docs: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation

# Tokens are issued by gete from the App's private key, not handed over by
# Gemini Enterprise, so nobody authorizes anything. The root is both where
# tokens are issued and where they are sent. GitHub Enterprise Server serves
# the API from a root of its own; setting base_url in gete.yaml replaces this
# one, and api.github.com is then no longer a host the token may go to.
base_url: https://api.github.com
# ghs_ is an installation access token, the only kind this connection issues.
token_prefixes:
- ghs_

# Which App, where its key lives, and what every token is narrowed to differ
# per installation; gete.yaml fills them in.
app: {}

setup: |
Create a GitHub App (or pick an existing one) and install it on the
account that owns the repositories.

- Grant the App no more than the permissions the agents need; a token
can be narrowed below the installation's grant, never above it.
- Generate a private key and store the PEM in Secret Manager under the
name given as app.private_key_secret. The key never appears in a
declaration.
- In gete.yaml, set app.app_id, app.private_key_secret,
app.repositories (owner/name, all under one owner), and
app.permissions. Every token gete issues is limited to them.

Anyone who can call an agent holding this connection acts as the App
within that limit, whatever they could reach on GitHub themselves.

examples:
accepts:
- "ghs_16C7e42F292c6912E7710c838347Ae178B4a"
rejects:
- "ya29.a0AfH6SMB" # Google access token
- "eyJhbGciOiJSUzI1NiJ9.e30.sig" # JWT
- "gho_16C7e42F292c6912E7710c838347Ae178B4a" # a user's OAuth token
- "ghu_16C7e42F292c6912E7710c838347Ae178B4a" # a user-to-server token
7 changes: 4 additions & 3 deletions src/gete/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,18 @@ def run(name: str) -> None:
find_agent,
initial_state,
missing_tokens,
user_authorized,
)

try:
project = load_project(find_project_file(Path.cwd()))
agent = build_local_agent(project, name)
declared = find_agent(project, name)
authorized = user_authorized(project, find_agent(project, name))
except GeteError as error:
click.echo(str(error), err=True)
sys.exit(1)
state = initial_state(name, declared.connections, os.environ)
missing = missing_tokens(name, declared.connections, state)
state = initial_state(name, authorized, os.environ)
missing = missing_tokens(name, authorized, state)
if missing:
click.echo(
f"no token for {', '.join(missing)}; set GETE_TOKEN_<CONNECTION>", err=True
Expand Down
70 changes: 57 additions & 13 deletions src/gete/connection/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
from collections.abc import Iterable
from urllib.parse import urlsplit

from gete.connection.registry import GOOGLE_ACCESS_TOKEN_PREFIX, Connection, Registry
from gete.connection.registry import (
GOOGLE_ACCESS_TOKEN_PREFIX,
Connection,
OAuth,
Registry,
)

# Platform domains under which unrelated parties host services. Hosts are
# matched exactly, so listing one of these is almost certainly a mistake
Expand Down Expand Up @@ -182,18 +187,8 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str]
f"tokens: format {connection.token_format} decides on its own; the "
"token_prefixes declared beside it are never read"
)
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"
)
if connection.oauth is not None:
problems.extend(_oauth_problems(connection.oauth))
for token in connection.examples.accepts:
if not connection.accepts_token(token):
problems.append(f"examples.accepts: {token!r} is not accepted")
Expand All @@ -218,3 +213,52 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str]
):
problems.append(f"mcp.url: {connection.mcp_url} is not covered by hosts")
return problems


def _oauth_problems(oauth: OAuth) -> list[str]:
problems: list[str] = []
for scope in sorted(oauth.optional_scopes):
if scope in oauth.scopes:
problems.append(
f"oauth.optional_scopes: {scope} is already a default scope"
)
if oauth.optional_scopes and 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"
)
return problems


def app_problems(connection: Connection) -> list[str]:
"""What an app connection still lacks before a token can be issued.

A catalog entry leaves the App open, the way it leaves a moving root
open, so the gap is refused where an agent picks the connection up.
"""
app = connection.app
if app is None:
return []
where = f"connections.{connection.id}.app"
problems = [
f"{connection.id} has no app.{name}; set {where}.{name} in gete.yaml"
for name, value in (
("app_id", app.app_id),
("private_key_secret", app.private_key_secret),
("repositories", app.repositories),
# Without it a token carries everything the installation was
# granted, which is exactly what the ceiling is there to stop.
("permissions", app.permissions),
)
if not value
]
owners = sorted({repository.partition("/")[0] for repository in app.repositories})
if len(owners) > 1:
problems.append(
f"{connection.id}: app.repositories belong to {', '.join(owners)}; a "
"token is issued by one installation, and an installation belongs "
"to one account"
)
return problems
23 changes: 20 additions & 3 deletions src/gete/connection/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

import httpx

from gete.connection.github_app import AppTokenUnavailable, installation_tokens
from gete.connection.registry import Connection
from gete.connection.runtime import caller_token, resolve_connection
from gete.errors import GeteError, UserFacingError
Expand Down Expand Up @@ -371,7 +372,10 @@ async def get_bytes(
await response.aclose()
_check_redirect(connection, target)
headers = (
{**self._headers, **self._authorization(connection, target, state)}
{
**self._headers,
**await self._authorization(connection, target, state),
}
if connection.allows(target)
# Off the connection's own hosts nothing of ours travels: not
# the token, and not the constants that name this service.
Expand All @@ -392,9 +396,17 @@ async def get_bytes(
def _connection(self) -> Connection:
return resolve_connection(self._target)

def _authorization(
async def _authorization(
self, connection: Connection, url: str, state: Any
) -> dict[str, str]:
if connection.app is not None:
try:
issued = await installation_tokens(connection).token()
except AppTokenUnavailable as error:
# Not a reauthorization: there is nothing for the user to
# approve, and the reason was written to be shown.
raise AuthorizationRefused(str(error)) from None
return {"Authorization": f"Bearer {issued}"}
token = caller_token(connection, state)
if token is None:
# The user sees a re-authorization prompt; operators would not.
Expand Down Expand Up @@ -429,7 +441,7 @@ async def _request(
# is put in last, so nothing can displace it.
sent = httpx.Headers(self._headers)
sent.update(_refuse_masking_headers(headers or {}))
sent.update(self._authorization(connection, url, state))
sent.update(await self._authorization(connection, url, state))
# A GET can be sent again because sending it again changes nothing.
# Anything else may already have been applied by the time the answer
# went missing, so only a refusal the service made before acting - a
Expand Down Expand Up @@ -457,6 +469,11 @@ async def _request(

if response.status_code == 401:
await response.aclose()
if connection.app is not None:
# An issued token refused before its time would refuse
# every request until it expired; the next one is issued
# afresh.
installation_tokens(connection).forget()
# There is no way to refresh; authorization is Gemini Enterprise's job.
logger.warning(
"token for %s was rejected url=%s", connection.id, _loggable(url)
Expand Down
Loading
Loading