Skip to content

feat(signing): async_resolve_agent — bootstrap to JWKS via brand.json (#344)#389

Merged
bokelley merged 1 commit intomainfrom
bokelley/resolve-agent
May 3, 2026
Merged

feat(signing): async_resolve_agent — bootstrap to JWKS via brand.json (#344)#389
bokelley merged 1 commit intomainfrom
bokelley/resolve-agent

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

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)

Blocker Decision
SSRF on 3 hops Capabilities hop now pinned; brand.json + JWKS already were.
`identity_posture` / `consistency` Zero spec provenance — dropped from `AgentResolution`.
PSL extra Not needed yet (no eTLD+1 binding until Tier 3 #350). No `tldextract` dep added.
CLI alias collision `--resolve` flag, not positional.
Async-first surface `async_resolve_agent` canonical; `resolve_agent` thin sync wrapper.
`get_agent_jwks` redundancy Dropped — JWK set surfaces via `AgentResolution.jwks`.

Tests

11 unit tests in `tests/test_agent_resolver.py`:

  • happy path (3 hops succeed → AgentResolution + 3-entry trace)
  • each `AgentResolverErrorCode`: `capabilities_unreachable`, `capabilities_invalid`, `brand_json_url_missing`, `brand_json_resolution_failed`, `jwks_fetch_failed`
  • body-cap DoS guard
  • sync wrapper dispatch via `asyncio.run`
  • forward-compat read of `identity.brand_json_url` (3.0.5's `additionalProperties: true` makes the field readable via `model_extra` until 3.1 types it)

Out of scope (follow-up)

Local gates

  • `ruff check src/` — clean
  • `mypy src/adcp/` — 745 source files, no issues
  • `pytest tests/` — 3109 passed, 26 skipped, 1 xfailed
  • `scripts/regenerate_public_api_snapshot.py` — re-run to add the 6 new exports

Test plan

  • All 11 `tests/test_agent_resolver.py` cases pass locally
  • CLI smoke: `adcp --resolve https://nonexistent.invalid --agent-type sales --json` returns structured `AgentResolverError` JSON with `code: capabilities_unreachable`
  • Public-API snapshot regenerated and matches

🤖 Generated with Claude Code

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 bokelley merged commit 0d78bd1 into main May 3, 2026
12 checks passed
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>
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.

feat: implement identity.brand_json_url resolver + verifier (resolve_agent / verify_request_signature) + CLI

1 participant