feat(signing): async_resolve_agent — bootstrap to JWKS via brand.json (#344)#389
Merged
feat(signing): async_resolve_agent — bootstrap to JWKS via brand.json (#344)#389
Conversation
Closes #344 (resolver + CLI core). Verifier-side verify_from_agent_url factory + 8 typed request_signature_* errors remain a follow-up. Adds adcp.signing.agent_resolver with the 3-hop walk: agent_url -> get_adcp_capabilities -> identity.brand_json_url -> brand.json -> jwks_uri -> JWK set closing the SSRF gap on the capabilities hop (the existing brand.json + JWKS hops were already pinned via build_async_ip_pinned_transport). The capabilities fetcher is intentionally NOT routed through adcp.client.ADCPClient — that client is for trusted-counterparty traffic; here agent_url is attacker-shaped. Public surface: - async_resolve_agent(agent_url, *, agent_type, agent_id=None, ...) -> AgentResolution - resolve_agent(...) -> sync wrapper (asyncio.run) - AgentResolution: agent_url, brand_json_url, agent_entry, jwks_uri, jwks (RFC 7517 set), fetched_at, trace - TraceEntry: per-hop record (capabilities | brand_json | jwks) - AgentResolverError(code, message) with stable AgentResolverErrorCode - BrandJsonJwksResolver.jwks_uri: new property (5 LOC) consumed by the resolver after force_refresh Notably absent (deferred per design-decision triage on #344): - identity_posture / consistency: zero schema provenance in 3.0.5; dropped from AgentResolution to keep --json output cross-SDK clean. - get_agent_jwks: redundant with BrandJsonJwksResolver; the JWK set reaches adopters via AgentResolution.jwks instead. - tldextract: only relevant when verifier-side eTLD+1 binding lands (Tier 3 #350 / adcp#3690 territory). No PSL dep added in this PR. CLI: --resolve <agent-url> --agent-type <type> [--agent-id <id>] [--json]. Flag form (not positional subcommand) sidesteps the saved- alias collision code-reviewer flagged. 11 unit tests cover happy path + each AgentResolverErrorCode + body cap (DoS guard) + sync wrapper dispatch + forward-compat read of identity.brand_json_url via the 3.0.5 additionalProperties: true relaxation. Local gates: - ruff check src/ - clean - mypy src/adcp/ - 745 source files, no issues - pytest tests/ - 3109 passed, 26 skipped, 1 xfailed Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 3, 2026
…factory (#401) Composes async_resolve_agent + verify_starlette_request into one async entry point so verifiers handed an agent_url don't have to hand-wire the JWKS plumbing themselves. Closes the verifier-side half of #344 (resolver landed in #389). The factory: - Calls async_resolve_agent(agent_url, agent_type=...) for the JWK set - Constructs StaticJwksResolver over the resolved keys - Wraps in VerifyOptions and dispatches verify_starlette_request - Maps resolver-side AgentResolverError to SignatureVerificationError with the closest spec error code so adopters handle resolution + verification failures through one except clause: - invalid_agent_url -> JWKS_UNTRUSTED (trust-boundary rejection) - everything else (capabilities_unreachable, brand_json_resolution_failed, jwks_fetch_failed, etc.) -> JWKS_UNAVAILABLE (discovery-time failure) - Verifier-side SignatureVerificationError passes through unchanged (factory does NOT remap verifier failures) The original AgentResolverError chains via __cause__ so adopters who need finer-grain dispatch on the resolver-side code can read exc.__cause__.code directly. Note on the issue body's "8 typed request_signature_* exceptions" acceptance criterion: the existing SignatureVerificationError(code=...) already provides typed-exception semantics via the .code attribute, matching the BrandJsonResolverError(code=...) pattern the triage called out. There are 18 REQUEST_SIGNATURE_* code constants exported from adcp.signing.errors today; subclassing each would add 18 import lines per adopter for no semantic gain over `if exc.code == REQUEST_SIGNATURE_X:`. Following codebase convention. 5 unit tests cover happy path + 3 resolver-failure mappings + verifier- side passthrough. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Closes #344 (resolver + CLI core). The verifier-side `verify_from_agent_url` factory and the 8 typed `request_signature_*` exceptions ride the same orchestrator and are scoped to a follow-up PR.
What ships
`adcp.signing.agent_resolver` — async-first orchestrator that walks the 3-hop chain ADCP 3.x defines for keys-from-agent-URL discovery:
```
agent_url → get_adcp_capabilities → identity.brand_json_url
→ brand.json (operator-attested) → jwks_uri → JWK set
```
The capabilities hop is the SSRF gap this PR closes — brand.json and JWKS hops were already IP-pinned via `build_async_ip_pinned_transport`. The capabilities fetcher is intentionally not routed through `ADCPClient` because that client is for trusted-counterparty traffic; here `agent_url` is attacker-shaped.
Public surface
```python
from adcp.signing import async_resolve_agent, AgentResolution, AgentResolverError
result: AgentResolution = await async_resolve_agent(
"https://buyer.example.com/mcp\",
agent_type="sales",
)
result.brand_json_url, .jwks_uri, .jwks (RFC 7517), .agent_entry,
.fetched_at, .trace (per-hop)
```
`resolve_agent(...)` is the sync `asyncio.run(...)` wrapper for CLI / scripts. `BrandJsonJwksResolver.jwks_uri` gains a property mirroring the existing `agent_url` property (5 LOC; consumed by the resolver after `force_refresh`).
CLI
```
adcp --resolve https://buyer.example.com/mcp --agent-type sales
adcp --resolve https://buyer.example.com/mcp --agent-type sales --json | jq .jwks_uri
```
`--resolve` is a flag (not a positional subcommand) — sidesteps the saved-alias collision code-reviewer flagged in pre-PR triage.
Design decisions settled in pre-PR triage (see #344 comment)
Tests
11 unit tests in `tests/test_agent_resolver.py`:
Out of scope (follow-up)
Local gates
Test plan
🤖 Generated with Claude Code