You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Human-in-the-loop tool confirmation is forgeable by an A2A peer (self-approves dangerous tools) #6461
Dangerous FunctionTools (e.g. the shipped ExecuteBashTool) are gated by a human-in-the-loop confirmation, but the approval is only validated to come from an event whose author == "user". Inbound A2A messages are converted to role='user' turns, so a remote A2A peer can supply the {confirmed: true} approval and self-approve a pending dangerous tool call - a confused deputy across the A2A trust boundary. The confirmation is meant to require the human operator; a peer is not the operator.
Affected
google-adk (confirmed 2.5.0; same logic on main):
src/google/adk/flows/llm_flows/request_confirmation.py - the only trust check is event.author == "user"; the approval {confirmed: true} is parsed verbatim.
src/google/adk/tools/bash_tool.py - gate is tool_confirmation.confirmed, then create_subprocess_exec.
Agent configured with ExecuteBashTool. Over A2A, a peer sends a function_response DataPart for the pending adk_request_confirmation id with {confirmed: true} -> the tool executes. Negative control {confirmed: false} -> rejected. (Also reachable by any caller of /run / /run_sse, which have no auth by default.)
Impact
Defeats the only safety control on ExecuteBashTool (and any require-confirmation tool) for A2A peers and unauthenticated API callers. CWE-306 / confused deputy.
Fix
Refuse adk_request_confirmation approvals on A2A-originated invocations (a peer is not the operator). The /run vector additionally requires authenticating the ADK API server in production - the HITL confirmation is not a substitute for network auth. A PR follows.
Reported to Google (b/538096101); the team asked that it be disclosed publicly here.
@cybrdude One gap worth closing before merge: custom_metadata['a2a_metadata'] only gets set when request.metadata is non-empty, so a peer that sends the confirmation with no protocol metadata slips right past the guard.
Could you make that marker unconditional (even an empty dict) and key the check off presence rather than truthiness, then confirm that exact case is blocked too?
@cybrdude Verified against the latest commits — the marker's unconditional now (custom_metadata = {A2A_METADATA_KEY: meta_to_dict(request.metadata)}) and the guard keys off presence (_A2A_METADATA_KEY in custom_metadata), so an empty-metadata peer no longer slips past it.
test_convert_a2a_request_empty_metadata_still_marks_a2a and test_request_confirmation_processor_ignores_a2a_without_metadata cover exactly that case.
@cybrdude Read through #7134 end to end rather than the description alone and ran it myself rather than taking your numbers on faith.
Design matches what we agreed: CallerPrincipal carries authentication not transport; the gate in _confirmation.py runs after the consumed-confirmation dedup so multi-turn re-entry is a no-op; a refusal rewrites confirmed=False instead of dropping the confirmation so nothing stalls; STRICT_CALLER_PRINCIPAL is off by default through the feature registry same pattern as the other staged switches. Checked build_caller_principal against _get_user_id directly, it's a strict superset of the same branch condition plus the is_authenticated guard so they genuinely can't drift and your parametrized test pins that. Confirmed the scope claims too: Runner.run, _new_invocation_context_for_live and anything under cli/ are untouched so this doesn't quietly expand to live sessions or the /run HTTP body.
Applied the diff against current main, it's rebased cleanly and targets the relocated flows/llm_flows/tools/_confirmation.py not the shim. Ran the suites myself: test_confirmation.py 24/24, test_request_converter.py 25/25, test_runners.py 125/125 and the full flows + a2a + agents trees together, 2412 passed, 19 skipped, 2 xfailed, zero failures. That's the positive-control coverage the first attempt was missing plus no sign of the availability regression that got #6462 reverted.
This is the fix. Principal over transport, explicit refusal, off-by-default strict mode and a test suite that actually proves legitimate approvals still work. Nothing blocking from my read, moving this to second LGTM.
This has been resolved by PR #6462 and merged into the main branch in commit 9e9eaa6.
We are closing this issue as resolved. If you find that the issue persists on the latest ADK release or if this was closed in error, please feel free to reopen it or leave a comment! Thank you again for your contribution.
@DeanChensj9e9eaa6 was reverted on Aug 20 in 9a32eba ("revert the A2A guard that broke every HITL tool confirmation"), so main and every release since carry the original behaviour: a /run caller or an A2A peer can still answer the confirmation for a pending tool call. The replacement is #7134, open and rebased on current main, which gates the answer on the caller principal the serving layer established, for both the LlmAgent flow and the workflow tool node. Could this stay open until #7134 lands? I am reopening it so it does not drop off the tracker.
Activity
surajksharma07 commentedon Jul 24, 2026
@cybrdude One gap worth closing before merge: custom_metadata['a2a_metadata'] only gets set when request.metadata is non-empty, so a peer that sends the confirmation with no protocol metadata slips right past the guard.
Could you make that marker unconditional (even an empty dict) and key the check off presence rather than truthiness, then confirm that exact case is blocked too?
test(a2a): cover A2A confirmations with and without peer metadata
cybrdude commentedon Jul 24, 2026
@surajksharma07 thank you. I made the fixes within the PR.
surajksharma07 commentedon Jul 26, 2026
@cybrdude Verified against the latest commits — the marker's unconditional now (custom_metadata = {A2A_METADATA_KEY: meta_to_dict(request.metadata)}) and the guard keys off presence (_A2A_METADATA_KEY in custom_metadata), so an empty-metadata peer no longer slips past it.
test_convert_a2a_request_empty_metadata_still_marks_a2a and test_request_confirmation_processor_ignores_a2a_without_metadata cover exactly that case.
Team is looking more into it.
Thanks!
fix: reject tool confirmations arriving over A2A
fix: revert the A2A guard that broke every HITL tool confirmation
xuanyang15 commentedon Aug 21, 2026
Reopen the issue because the PR is reverted in 9a32eba.
15 remaining items
feat(agents): export CallerPrincipal from google.adk.agents
refactor(agents): import CallerPrincipal from the private module
refactor(runners): import CallerPrincipal from the private module
refactor(a2a): import CallerPrincipal from the private module
feat(features): register STRICT_CALLER_PRINCIPAL, off by default
refactor(flows): read the strict switch from the feature registry
test(flows): follow the module rename and the feature-registry switch
docs(agents): add the CallerPrincipal unit guide
docs: index the CallerPrincipal guide
cybrdude commentedon Sep 16, 2026
@xuanyang15 @surajksharma07 ready for review #7134
surajksharma07 commentedon Sep 18, 2026
@cybrdude Read through #7134 end to end rather than the description alone and ran it myself rather than taking your numbers on faith.
Design matches what we agreed: CallerPrincipal carries authentication not transport; the gate in _confirmation.py runs after the consumed-confirmation dedup so multi-turn re-entry is a no-op; a refusal rewrites confirmed=False instead of dropping the confirmation so nothing stalls; STRICT_CALLER_PRINCIPAL is off by default through the feature registry same pattern as the other staged switches. Checked build_caller_principal against _get_user_id directly, it's a strict superset of the same branch condition plus the is_authenticated guard so they genuinely can't drift and your parametrized test pins that. Confirmed the scope claims too: Runner.run, _new_invocation_context_for_live and anything under cli/ are untouched so this doesn't quietly expand to live sessions or the /run HTTP body.
Applied the diff against current main, it's rebased cleanly and targets the relocated flows/llm_flows/tools/_confirmation.py not the shim. Ran the suites myself: test_confirmation.py 24/24, test_request_converter.py 25/25, test_runners.py 125/125 and the full flows + a2a + agents trees together, 2412 passed, 19 skipped, 2 xfailed, zero failures. That's the positive-control coverage the first attempt was missing plus no sign of the availability regression that got #6462 reverted.
This is the fix. Principal over transport, explicit refusal, off-by-default strict mode and a test suite that actually proves legitimate approvals still work. Nothing blocking from my read, moving this to second LGTM.
Team is looking more into it.
DeanChensj commentedon Oct 14, 2026
Hi @cybrdude, thank you for reporting this issue!
This has been resolved by PR #6462 and merged into the main branch in commit 9e9eaa6.
We are closing this issue as resolved. If you find that the issue persists on the latest ADK release or if this was closed in error, please feel free to reopen it or leave a comment! Thank you again for your contribution.
github-actions commentedon Oct 14, 2026
Are you satisfied with the resolution of your issue?
Yes
No
cybrdude commentedon Oct 15, 2026
@DeanChensj 9e9eaa6 was reverted on Aug 20 in 9a32eba ("revert the A2A guard that broke every HITL tool confirmation"), so main and every release since carry the original behaviour: a /run caller or an A2A peer can still answer the confirmation for a pending tool call. The replacement is #7134, open and rebased on current main, which gates the answer on the caller principal the serving layer established, for both the LlmAgent flow and the workflow tool node. Could this stay open until #7134 lands? I am reopening it so it does not drop off the tracker.