Let two prefixless connections share an agent when one names its issuer - #56
Merged
Conversation
An agent that reads one service and writes to another was refused when both connections declared token_prefixes: [], because only one of an agent's connections may accept tokens by elimination. The rule is right - two anonymous connections cannot be told apart, and a token from either authorization would pass as the other's - but it was blunter than the runtime it protects. Since tokens are judged by the issuer their claims name, a service whose access tokens are JWTs issued by its own host announces itself in every token; validate simply had no way to know the tokens would arrive as JWTs. tokens.format: jwt is the word for it. The runtime holds the connection to it: a token that is not a JWT its own service issued is refused, whatever else would have taken it, so the declaration cannot loosen validate while promising nothing where it counts. A format this gete cannot judge accepts nothing rather than falling back to elimination - a resolved declaration outlives the gete that wrote it. Only connections with neither prefixes nor a format are now counted as accepting by elimination, so one anonymous connection per agent stays the ceiling and a declaring one no longer occupies it. Two declaring connections that name one issuer host are refused together for the same reason the anonymous pair is: neither could tell the other's token from its own. Whether a provider issues such tokens can be a per-client setting - Zendesk does only with token expiry turned on - so the catalog must not promise it for every installation, and the declaration belongs in gete.yaml. Zendesk's setup says so next to the setting, including what it costs: turn expiry off again and every token is refused until the declaration goes with it. Its accepted example had to go, because which of the two shapes arrives is that setting and an example fixing one would contradict the other; what is refused is refused either way. gete connections prints the declared format where it printed "(none: by elimination)", which is the difference that decides what an agent may hold, and a refusal now names the promise the token missed rather than leaving an operator to guess at a log full of them. 🤖 Generated with Claude Code
The declaration was let out of the elimination count on the grounds that its tokens name their issuer, and two declaring connections naming one issuer were refused together. An anonymous connection judges the same way: it takes a JWT whose iss names its own authorization server and refuses one naming anything else. So a single authorization server in front of two services confuses a declaring connection and an anonymous one exactly as it confuses two declaring ones - and that pairing is the one the declaration was written to allow, so nothing else was left to refuse it. Two services behind one authorization server is not an exotic shape, and validate took them. The issuer check now runs over every connection that judges a token by who issued it, declaring or not. The anonymous pair is skipped there because it is already refused for saying nothing about itself at all, and a connection held to a prefix stays out entirely: the prefix decides before any issuer does, so no token of another's reaches its issuer. describe_token read the issuer before the format, so a connection declaring a format this gete cannot judge answered "a JWT whose issuer does not name this service" about a token its own service had issued. What refused it is the declaration being newer than the gete reading it, and that is where the operator has to be sent - not after a token that may be exactly right. token_format was read as a truth value in five places and against None in accepts_token. Against None everywhere now, so the site that decides what a token is judged by cannot drift from the ones that decide whether an agent may hold the connection at all. 🤖 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
An agent that reads one service and writes to another is refused today when both connections declare
token_prefixes: []:elimination_problemsallows at most one connection per agent to accept tokens by elimination. The rule is right — two fully anonymous connections cannot be told apart, and a token from either authorization would pass as the other's — but it is blunter than the runtime it protects. Since tokens are judged by the issuer their claims name (accepts_token→issued_here), a provider whose access tokens are JWTs issued by its own host announces itself in every token. It is not anonymous at all;validatejust had no way to know the tokens would arrive as JWTs.What
tokens: {format: jwt}on a connection says its tokens are JWTs whoseissclaim names the connection's own service (itshostsand its OAuth endpoints, the same setissued_herealready matched). Schema, README, andgete connectionsknow about it.accepts_tokenrefuses anything that is not such a JWT — an opaque token, a JWT whose claims cannot be read, a JWT that names no issuer or names someone else's — whatever else would have taken it. Without that, the declaration would loosenvalidatewhile promising nothing where it counts. A format this gete cannot judge (a resolved declaration outlives the gete that wrote it) accepts nothing rather than falling back to elimination.elimination_problemscounts only connections with neither prefixes nor a declared format. One anonymous connection per agent stays the ceiling; a jwt-declaring one no longer occupies it. The remaining anonymous one is already protected from the JWT side: a JWT whose issuer names the other service is refused byaccepts_tokentoday.gete connectionsprints(none: jwt issued by this service)where it printed(none: by elimination). That difference is what decides whether an agent may hold the connection beside another, so it must not read as the elimination it replaces.not the jwt this connection declares,a JWT that names no issuer— because an operator who turned the provider's setting off has every token refused at once and nothing else in the log would say why.Zendesk
Whether a provider issues JWTs can be a per-client setting — Zendesk does so only with token expiry turned on — so the catalog must not promise it for every installation. The entry keeps
token_prefixes: []; itssetupsays how to declare the format and what it costs, next to the setting itself: turn expiry off again and every Zendesk token is refused until the declaration goes with it.Its
examples.acceptshad to go. Which of the two shapes arrives is that same setting, so an example fixing one contradicts an installation that has the other — an installation declaring the format would have seenexamples.accepts: '...' is not acceptedfromvalidatefor a catalog entry it never wrote. What is refused is refused either way, and therejectslist stays.Tests
tokensare refusedaccepts_token: a JWT from the service's own hosts, OAuth endpoints and installation root is accepted; an opaque token, a foreign issuer, a missing issuer, unreadable claims, Google's access tokens and ID tokens are refused; a foreign prefix no longer decides; an unknown format accepts nothingelimination_problems: a declaring connection does not occupy the elimination slot, two from different services are fine, two sharing an issuer host are reported as one pairing; the anonymous pair is unchangedconnection_problems: prefixes beside a format are reportedvalidate, end to end:zendesk+internal-apion one agent passes with the declaration and still fails without itresolve: the declaration travels to the runtime and refuses theredescribe_tokennames the unmet promise,usable_tokenrefuses the opaque token without logging ituv run pytest(863 passed, e2e included),ruff check,ruff format --checkandmypyall pass.