diff --git a/src/google/adk/evaluation/_path_validation.py b/src/google/adk/evaluation/_path_validation.py new file mode 100644 index 0000000000..b9bc0db97e --- /dev/null +++ b/src/google/adk/evaluation/_path_validation.py @@ -0,0 +1,40 @@ +# 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. + +from __future__ import annotations + + +def validate_path_segment(value: str, field_name: str) -> None: + """Rejects values that could alter a filesystem path. + + Args: + value: The caller-supplied identifier. + field_name: Human-readable field name used in error messages. + + Raises: + ValueError: If the value contains path separators, traversal segments, or + null bytes. + """ + if not value: + raise ValueError(f"{field_name} must not be empty.") + if "\x00" in value: + raise ValueError(f"{field_name} must not contain null bytes.") + if "/" in value or "\\" in value: + raise ValueError( + f"{field_name} {value!r} must not contain path separators." + ) + if value in (".", ".."): + raise ValueError( + f"{field_name} {value!r} must not contain traversal segments." + ) diff --git a/src/google/adk/evaluation/local_eval_set_results_manager.py b/src/google/adk/evaluation/local_eval_set_results_manager.py index c6da638abe..dabc0b38b1 100644 --- a/src/google/adk/evaluation/local_eval_set_results_manager.py +++ b/src/google/adk/evaluation/local_eval_set_results_manager.py @@ -22,6 +22,7 @@ from ..errors.not_found_error import NotFoundError from ._eval_set_results_manager_utils import create_eval_set_result from ._eval_set_results_manager_utils import parse_eval_set_result_json +from ._path_validation import validate_path_segment from .eval_result import EvalCaseResult from .eval_result import EvalSetResult from .eval_set_results_manager import EvalSetResultsManager @@ -46,6 +47,8 @@ def save_eval_set_result( eval_case_results: list[EvalCaseResult], ) -> None: """Creates and saves a new EvalSetResult given eval_case_results.""" + validate_path_segment(app_name, "app_name") + validate_path_segment(eval_set_id, "eval_set_id") eval_set_result = create_eval_set_result( app_name, eval_set_id, eval_case_results ) @@ -67,6 +70,7 @@ def get_eval_set_result( self, app_name: str, eval_set_result_id: str ) -> EvalSetResult: """Returns an EvalSetResult identified by app_name and eval_set_result_id.""" + validate_path_segment(eval_set_result_id, "eval_set_result_id") # Load the eval set result file data. maybe_eval_result_file_path = ( os.path.join( @@ -97,4 +101,5 @@ def list_eval_set_results(self, app_name: str) -> list[str]: return eval_result_files def _get_eval_history_dir(self, app_name: str) -> str: + validate_path_segment(app_name, "app_name") return os.path.join(self._agents_dir, app_name, _ADK_EVAL_HISTORY_DIR) diff --git a/src/google/adk/evaluation/local_eval_sets_manager.py b/src/google/adk/evaluation/local_eval_sets_manager.py index 8d2290b911..75ba9973d6 100644 --- a/src/google/adk/evaluation/local_eval_sets_manager.py +++ b/src/google/adk/evaluation/local_eval_sets_manager.py @@ -33,6 +33,7 @@ from ._eval_sets_manager_utils import get_eval_case_from_eval_set from ._eval_sets_manager_utils import get_eval_set_from_app_and_id from ._eval_sets_manager_utils import update_eval_case_in_eval_set +from ._path_validation import validate_path_segment from .eval_case import EvalCase from .eval_case import IntermediateData from .eval_case import Invocation @@ -247,6 +248,7 @@ def list_eval_sets(self, app_name: str) -> list[str]: Raises: NotFoundError: If the eval directory for the app is not found. """ + validate_path_segment(app_name, "app_name") eval_set_file_path = os.path.join(self._agents_dir, app_name) eval_sets = [] try: @@ -310,6 +312,8 @@ def delete_eval_case( self._save_eval_set(app_name, eval_set_id, updated_eval_set) def _get_eval_set_file_path(self, app_name: str, eval_set_id: str) -> str: + validate_path_segment(app_name, "app_name") + validate_path_segment(eval_set_id, "eval_set_id") return os.path.join( self._agents_dir, app_name, diff --git a/tests/unittests/evaluation/test_local_eval_set_results_manager.py b/tests/unittests/evaluation/test_local_eval_set_results_manager.py index 4647392628..01e08f1f41 100644 --- a/tests/unittests/evaluation/test_local_eval_set_results_manager.py +++ b/tests/unittests/evaluation/test_local_eval_set_results_manager.py @@ -92,6 +92,22 @@ def test_save_eval_set_result(self, mocker): expected_eval_set_result_data = self.eval_set_result.model_dump(mode="json") assert expected_eval_set_result_data == actual_eval_set_result_data + @pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"]) + def test_save_eval_set_result_rejects_invalid_app_name(self, app_name): + with pytest.raises(ValueError): + self.manager.save_eval_set_result( + app_name, self.eval_set_id, self.eval_case_results + ) + + @pytest.mark.parametrize( + "eval_set_id", ["", ".", "..", "foo/bar", "foo\\bar"] + ) + def test_save_eval_set_result_rejects_invalid_eval_set_id(self, eval_set_id): + with pytest.raises(ValueError): + self.manager.save_eval_set_result( + self.app_name, eval_set_id, self.eval_case_results + ) + def test_get_eval_set_result(self, mocker): mock_time = mocker.patch("time.time") mock_time.return_value = self.timestamp @@ -103,6 +119,20 @@ def test_get_eval_set_result(self, mocker): ) assert retrieved_result == self.eval_set_result + @pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"]) + def test_get_eval_set_result_rejects_invalid_app_name(self, app_name): + with pytest.raises(ValueError): + self.manager.get_eval_set_result(app_name, self.eval_set_result_name) + + @pytest.mark.parametrize( + "eval_set_result_id", ["", ".", "..", "foo/bar", "foo\\bar"] + ) + def test_get_eval_set_result_rejects_invalid_eval_set_result_id( + self, eval_set_result_id + ): + with pytest.raises(ValueError): + self.manager.get_eval_set_result(self.app_name, eval_set_result_id) + def test_get_eval_set_result_double_encoded_legacy(self): eval_history_dir = os.path.join( self.agents_dir, self.app_name, _ADK_EVAL_HISTORY_DIR diff --git a/tests/unittests/evaluation/test_local_eval_sets_manager.py b/tests/unittests/evaluation/test_local_eval_sets_manager.py index 3450fb9338..8632a2a786 100644 --- a/tests/unittests/evaluation/test_local_eval_sets_manager.py +++ b/tests/unittests/evaluation/test_local_eval_sets_manager.py @@ -395,6 +395,29 @@ def test_local_eval_sets_manager_create_eval_set_invalid_id( with pytest.raises(ValueError, match="Invalid Eval Set ID"): local_eval_sets_manager.create_eval_set(app_name, eval_set_id) + @pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"]) + def test_local_eval_sets_manager_create_eval_set_rejects_invalid_app_name( + self, local_eval_sets_manager, app_name + ): + with pytest.raises(ValueError): + local_eval_sets_manager.create_eval_set(app_name, "test_eval_set") + + @pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"]) + def test_local_eval_sets_manager_list_eval_sets_rejects_invalid_app_name( + self, local_eval_sets_manager, app_name + ): + with pytest.raises(ValueError): + local_eval_sets_manager.list_eval_sets(app_name) + + @pytest.mark.parametrize( + "eval_set_id", ["", ".", "..", "foo/bar", "foo\\bar"] + ) + def test_local_eval_sets_manager_get_eval_set_rejects_invalid_eval_set_id( + self, local_eval_sets_manager, eval_set_id + ): + with pytest.raises(ValueError): + local_eval_sets_manager.get_eval_set("test_app", eval_set_id) + def test_local_eval_sets_manager_create_eval_set_already_exists( self, local_eval_sets_manager, mocker ):