diff --git a/contributing/samples/a2a/a2a_human_in_loop/agent.py b/contributing/samples/a2a/a2a_human_in_loop/agent.py index bd0044598f6..b0231526fb1 100644 --- a/contributing/samples/a2a/a2a_human_in_loop/agent.py +++ b/contributing/samples/a2a/a2a_human_in_loop/agent.py @@ -13,16 +13,56 @@ # limitations under the License. +from typing import Any + from google.adk.agents.llm_agent import Agent from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH from google.adk.agents.remote_a2a_agent import RemoteA2aAgent from google.adk.apps import App from google.adk.apps import ResumabilityConfig +from google.adk.tools.tool_context import ToolContext from google.genai import types +from .approval_config import get_approval_threshold_usd +from .approval_config import requires_manager_approval + + +def reimburse( + purpose: str, amount: float, tool_context: ToolContext +) -> dict[str, Any]: + """Reimburse the amount of money to the employee. -def reimburse(purpose: str, amount: float) -> str: - """Reimburse the amount of money to the employee.""" + Whether this amount needs manager confirmation is decided by a server-side, + config-derived threshold (see approval_config.py) -- not by this function's + arguments, and not only by the agent's instruction text. `amount` at or + above the threshold cannot be reimbursed by a direct call to this tool: the + call is parked pending confirmation, and only executes once + `tool_context.tool_confirmation.confirmed` is True. This closes the gap + where an instruction telling the model to delegate large amounts to + `approval_agent` was the *only* thing standing between a large amount and + this tool actually running. + """ + if requires_manager_approval(amount): + if not tool_context.tool_confirmation: + tool_context.request_confirmation( + hint=( + f'Reimbursement of ${amount} for {purpose!r} is at or above' + f' the ${get_approval_threshold_usd():.2f} auto-approval' + ' threshold and requires manager confirmation.' + ), + ) + return { + 'status': 'pending_confirmation', + 'error': ( + 'This reimbursement requires manager confirmation before it' + ' can be processed.' + ), + } + if not tool_context.tool_confirmation.confirmed: + return { + 'status': 'rejected', + 'error': 'Reimbursement was not confirmed.', + } return { 'status': 'ok', } diff --git a/contributing/samples/a2a/a2a_human_in_loop/approval_config.py b/contributing/samples/a2a/a2a_human_in_loop/approval_config.py new file mode 100644 index 00000000000..d84e8495a28 --- /dev/null +++ b/contributing/samples/a2a/a2a_human_in_loop/approval_config.py @@ -0,0 +1,64 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Server-side, config-derived approval threshold for this sample. + +The threshold that decides whether a reimbursement needs manager confirmation +must be resolved from server-side configuration only, and must never be +something a tool caller -- the model, or anyone crafting tool-call arguments -- +can pass in and change. This is the general shape of a real, previously-seen +production bug: a caller-controlled value (a threshold, a confidence score, a +spend limit) silently overrode the value an approval gate was supposed to +enforce, defeating the gate. The fix is not to trust a prompt instruction to +keep the model from calling the tool for large amounts (that is what this +sample did before this change) -- it is to make the tool itself refuse, +in code, using a value the caller has no argument to influence. + +Nothing in this module accepts a threshold from a caller: the only input is +an environment variable read at call time, set by whoever deploys the agent, +never by a tool-call argument. +""" + +from __future__ import annotations + +import os + +# Name of the environment variable a deployer can set to change the +# threshold. This is intentionally NOT a parameter of `reimburse()` or any +# other tool function in this sample. +APPROVAL_THRESHOLD_ENV_VAR = 'REIMBURSEMENT_APPROVAL_THRESHOLD_USD' +DEFAULT_APPROVAL_THRESHOLD_USD = 100.0 + + +def get_approval_threshold_usd() -> float: + """Returns the current auto-approval threshold, in USD. + + Reads `REIMBURSEMENT_APPROVAL_THRESHOLD_USD` from the environment if set + (server-side config), otherwise falls back to + `DEFAULT_APPROVAL_THRESHOLD_USD`. There is deliberately no code path here + that lets a tool-call argument, or any caller-supplied dict, influence this + value. + """ + raw = os.environ.get(APPROVAL_THRESHOLD_ENV_VAR) + if raw is None: + return DEFAULT_APPROVAL_THRESHOLD_USD + try: + return float(raw) + except ValueError: + return DEFAULT_APPROVAL_THRESHOLD_USD + + +def requires_manager_approval(amount: float) -> bool: + """Whether `amount` requires manager confirmation before it is reimbursed.""" + return amount >= get_approval_threshold_usd() diff --git a/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/agent.py b/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/agent.py index 89a4282f6e1..0bc4c751a9b 100644 --- a/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/agent.py +++ b/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/agent.py @@ -19,9 +19,43 @@ from google.adk.tools.tool_context import ToolContext from google.genai import types +from .approval_config import get_approval_threshold_usd +from .approval_config import requires_manager_approval -def reimburse(purpose: str, amount: float) -> str: - """Reimburse the amount of money to the employee.""" + +def reimburse( + purpose: str, amount: float, tool_context: ToolContext +) -> dict[str, Any]: + """Reimburse the amount of money to the employee. + + Whether this amount needs manager confirmation is decided by a server-side, + config-derived threshold (see approval_config.py) -- not by this function's + arguments, and not only by the agent's instruction text. `amount` at or + above the threshold cannot be reimbursed by a direct call to this tool: the + call is parked pending confirmation, and only executes once + `tool_context.tool_confirmation.confirmed` is True. + """ + if requires_manager_approval(amount): + if not tool_context.tool_confirmation: + tool_context.request_confirmation( + hint=( + f'Reimbursement of ${amount} for {purpose!r} is at or above' + f' the ${get_approval_threshold_usd():.2f} auto-approval' + ' threshold and requires manager confirmation.' + ), + ) + return { + 'status': 'pending_confirmation', + 'error': ( + 'This reimbursement requires manager confirmation before it' + ' can be processed.' + ), + } + if not tool_context.tool_confirmation.confirmed: + return { + 'status': 'rejected', + 'error': 'Reimbursement was not confirmed.', + } return { 'status': 'ok', } diff --git a/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/approval_config.py b/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/approval_config.py new file mode 100644 index 00000000000..d84e8495a28 --- /dev/null +++ b/contributing/samples/a2a/a2a_human_in_loop/remote_a2a/human_in_loop/approval_config.py @@ -0,0 +1,64 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Server-side, config-derived approval threshold for this sample. + +The threshold that decides whether a reimbursement needs manager confirmation +must be resolved from server-side configuration only, and must never be +something a tool caller -- the model, or anyone crafting tool-call arguments -- +can pass in and change. This is the general shape of a real, previously-seen +production bug: a caller-controlled value (a threshold, a confidence score, a +spend limit) silently overrode the value an approval gate was supposed to +enforce, defeating the gate. The fix is not to trust a prompt instruction to +keep the model from calling the tool for large amounts (that is what this +sample did before this change) -- it is to make the tool itself refuse, +in code, using a value the caller has no argument to influence. + +Nothing in this module accepts a threshold from a caller: the only input is +an environment variable read at call time, set by whoever deploys the agent, +never by a tool-call argument. +""" + +from __future__ import annotations + +import os + +# Name of the environment variable a deployer can set to change the +# threshold. This is intentionally NOT a parameter of `reimburse()` or any +# other tool function in this sample. +APPROVAL_THRESHOLD_ENV_VAR = 'REIMBURSEMENT_APPROVAL_THRESHOLD_USD' +DEFAULT_APPROVAL_THRESHOLD_USD = 100.0 + + +def get_approval_threshold_usd() -> float: + """Returns the current auto-approval threshold, in USD. + + Reads `REIMBURSEMENT_APPROVAL_THRESHOLD_USD` from the environment if set + (server-side config), otherwise falls back to + `DEFAULT_APPROVAL_THRESHOLD_USD`. There is deliberately no code path here + that lets a tool-call argument, or any caller-supplied dict, influence this + value. + """ + raw = os.environ.get(APPROVAL_THRESHOLD_ENV_VAR) + if raw is None: + return DEFAULT_APPROVAL_THRESHOLD_USD + try: + return float(raw) + except ValueError: + return DEFAULT_APPROVAL_THRESHOLD_USD + + +def requires_manager_approval(amount: float) -> bool: + """Whether `amount` requires manager confirmation before it is reimbursed.""" + return amount >= get_approval_threshold_usd() diff --git a/tests/unittests/test_a2a_human_in_loop_approval_guard.py b/tests/unittests/test_a2a_human_in_loop_approval_guard.py new file mode 100644 index 00000000000..a0370759496 --- /dev/null +++ b/tests/unittests/test_a2a_human_in_loop_approval_guard.py @@ -0,0 +1,205 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Approval-threshold guard tests for the a2a_human_in_loop sample. + +Rule under test: contributing/samples/a2a/a2a_human_in_loop/approval_config.py +and the `reimburse()` tools in agent.py / remote_a2a/human_in_loop/agent.py. +The approval threshold that decides whether a reimbursement needs manager +confirmation must (a) be resolved from server-side config only -- a caller +has no tool-call argument through which to raise or lower it -- and (b) a +`reimburse()` call for an amount at or above that threshold must not be able +to bypass manager confirmation via a direct call to the tool function, i.e. +there is no code path to "status": "ok" for a large amount other than +through a confirmed `ToolConfirmation`. + +Both `agent.py` (the local root agent) and `remote_a2a/human_in_loop/agent.py` +(the remote A2A approval agent) define their own copy of `reimburse()` -- +mirroring the sample's own pre-existing duplication of that function -- so +this test module parametrizes over both to cover both copies. +""" + +from __future__ import annotations + +import importlib +import inspect +from pathlib import Path +import sys +from unittest.mock import MagicMock + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.sessions.session import Session +from google.adk.tools.tool_confirmation import ToolConfirmation +from google.adk.tools.tool_context import ToolContext +import pytest + +# ToolConfirmation is gated behind an experimental feature flag, which emits a +# UserWarning on use; expected and not under test here (same pattern as +# tests/unittests/tools/test_tool_confirmation.py). +pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") + +_CONTRIBUTING_DIR = Path(__file__).parent.parent.parent / "contributing" +_SAMPLE_DIR = _CONTRIBUTING_DIR / "samples" / "a2a" / "a2a_human_in_loop" +_REMOTE_SAMPLE_DIR = _SAMPLE_DIR / "remote_a2a" / "human_in_loop" + + +def _mock_tool_context(*, function_call_id: str = "test-call-1") -> ToolContext: + """Builds a minimal ToolContext, following test_function_tool.py's pattern.""" + mock_invocation_context = MagicMock(spec=InvocationContext) + mock_invocation_context._state_schema = None + mock_invocation_context.session = MagicMock(spec=Session) + mock_invocation_context.session.state = MagicMock() + return ToolContext( + invocation_context=mock_invocation_context, + function_call_id=function_call_id, + ) + + +def _import_agent_module(sample_dir: Path): + """Imports `.agent` the way tests/unittests/test_samples.py's + `_load_root_agent` loads a sample: put the sample's *parent* directory on + sys.path (so `agent.py`'s own `from .approval_config import ...` relative + import resolves against the sample's `__init__.py` package), import the + dotted module path, and evict the sample's modules from sys.modules + afterwards so re-importing with a different env var (see the env-var test + below) picks up fresh module state rather than a cached one. + """ + saved_modules = set(sys.modules) + saved_path = list(sys.path) + sys.path.insert(0, str(sample_dir.parent)) + try: + module_name = f"{sample_dir.name}.agent" + sys.modules.pop(module_name, None) + sys.modules.pop(sample_dir.name, None) + return importlib.import_module(module_name) + finally: + sys.path[:] = saved_path + prefix = sample_dir.name + for name in set(sys.modules) - saved_modules: + if name == prefix or name.startswith(prefix + "."): + del sys.modules[name] + + +@pytest.fixture( + params=[_SAMPLE_DIR, _REMOTE_SAMPLE_DIR], + ids=["root_agent", "remote_approval_agent"], +) +def agent_module(request, monkeypatch): + monkeypatch.delenv("REIMBURSEMENT_APPROVAL_THRESHOLD_USD", raising=False) + return _import_agent_module(request.param) + + +def test_reimburse_signature_has_no_threshold_argument(agent_module) -> None: + """A caller cannot name a threshold override: no such parameter exists. + + This is the first half of the config-derived-field guard: the only inputs + `reimburse()` accepts are `purpose`, `amount`, and `tool_context` -- there + is no `threshold`/`approval_threshold_usd`/similar parameter a caller could + set to change what counts as "requires approval". + """ + params = set(inspect.signature(agent_module.reimburse).parameters) + assert params == {"purpose", "amount", "tool_context"} + + +def test_threshold_is_read_from_config_not_from_any_call_argument( + agent_module, +) -> None: + """The guard always re-derives the threshold from config, every call.""" + default_threshold = agent_module.get_approval_threshold_usd() + assert default_threshold == 100.0 # approval_config.DEFAULT_APPROVAL_THRESHOLD_USD + + tool_context = _mock_tool_context() + # Passing arbitrary extra state on the tool_context that *looks* like an + # attempted override must have no effect -- there is nothing in + # requires_manager_approval()/get_approval_threshold_usd() that reads it. + tool_context.state["approval_threshold_usd"] = 0 + tool_context.state["threshold"] = 0 + + assert agent_module.get_approval_threshold_usd() == default_threshold + assert agent_module.requires_manager_approval(default_threshold) is True + + +def test_large_reimbursement_cannot_bypass_approval_via_direct_call( + agent_module, +) -> None: + """A direct call for an amount at/above threshold never returns "ok". + + This is the "cannot bypass the approval step via a direct invocation" + property: `reimburse()` is the *only* entry point (there is no second, + ungated function to call instead), and calling it directly -- exactly the + way a model that ignored its instructions, or a compromised/prompt-injected + caller, would -- must not execute the reimbursement. + """ + threshold = agent_module.get_approval_threshold_usd() + tool_context = _mock_tool_context() + + result = agent_module.reimburse( + purpose="new laptop", amount=threshold + 1, tool_context=tool_context + ) + + assert result["status"] != "ok" + # A confirmation must actually have been requested, not merely refused. + assert tool_context.actions.requested_tool_confirmations + + +def test_rejected_confirmation_still_refuses_execution(agent_module) -> None: + threshold = agent_module.get_approval_threshold_usd() + tool_context = _mock_tool_context() + tool_context.tool_confirmation = ToolConfirmation(confirmed=False) + + result = agent_module.reimburse( + purpose="new laptop", amount=threshold + 1, tool_context=tool_context + ) + + assert result["status"] == "rejected" + + +def test_confirmed_approval_allows_large_reimbursement(agent_module) -> None: + """The gate is not a black hole: an explicitly confirmed call still works.""" + threshold = agent_module.get_approval_threshold_usd() + tool_context = _mock_tool_context() + tool_context.tool_confirmation = ToolConfirmation(confirmed=True) + + result = agent_module.reimburse( + purpose="new laptop", amount=threshold + 1, tool_context=tool_context + ) + + assert result["status"] == "ok" + + +def test_small_amount_executes_without_any_confirmation(agent_module) -> None: + threshold = agent_module.get_approval_threshold_usd() + tool_context = _mock_tool_context() + + result = agent_module.reimburse( + purpose="lunch", amount=threshold - 1, tool_context=tool_context + ) + + assert result["status"] == "ok" + assert not tool_context.actions.requested_tool_confirmations + + +def test_deploy_time_env_var_moves_threshold_but_stays_server_side( + monkeypatch, +) -> None: + """Only a deployer's env var can move the threshold -- never a call arg.""" + monkeypatch.setenv("REIMBURSEMENT_APPROVAL_THRESHOLD_USD", "10") + agent_mod = _import_agent_module(_SAMPLE_DIR) + assert agent_mod.get_approval_threshold_usd() == 10.0 + + tool_context = _mock_tool_context() + result = agent_mod.reimburse( + purpose="coffee", amount=20, tool_context=tool_context + ) + assert result["status"] != "ok"