From 4ac9da641a64aca63d0b8345e9bb5abda414da02 Mon Sep 17 00:00:00 2001 From: varunj-msft Date: Tue, 13 Oct 2026 02:22:02 +0000 Subject: [PATCH 1/3] Keep targets that load model code out of API creation HuggingFaceChatTarget starts loading its model, optionally with trust_remote_code, as soon as it is constructed. Targets that set loads_local_code are now left out of the target catalog and rejected by POST /api/targets. Python and initializer registration are unchanged. --- pyrit/backend/services/target_service.py | 19 ++++++++---- pyrit/prompt_target/common/prompt_target.py | 4 +++ .../hugging_face/hugging_face_chat_target.py | 4 ++- tests/unit/backend/test_target_service.py | 29 +++++++++++++++++-- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index eb56dbce62..a3f1e6c5ca 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -38,7 +38,6 @@ "AzureBlobStorageTarget": frozenset({"container_url"}), "AzureMLChatTarget": frozenset({"endpoint"}), "HackAPromptTarget": frozenset({"cookie", "session_id"}), - "HuggingFaceChatTarget": frozenset({"hf_access_token"}), "PromptShieldTarget": frozenset({"endpoint"}), } @@ -192,7 +191,8 @@ async def list_target_types_async(self) -> TargetTypeResponse: constructor parameters they may supply, each described in the form callers send it, and the auth modes it supports, all projected from the registry's ``TargetMetadata``; targets that need a Python object for a required - parameter are left out. Deciding which entries to surface to a + parameter, and targets that load model code on the server, are left out. + Deciding which entries to surface to a user is a presentation concern owned by the caller (e.g. the frontend), not this service. @@ -216,6 +216,7 @@ async def list_target_types_async(self) -> TargetTypeResponse: ) for metadata in metadata_items if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required) + and not self._registry.get_class(metadata.class_name).loads_local_code ] return TargetTypeResponse(items=items) @@ -240,10 +241,11 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn TargetInstance with the new target's details. Raises: - ValueError: If the target type is not registered or identity auth is - requested but unsupported by the target type. Construction errors - (unknown params, incompatible inner targets, unrecognized identity - endpoints) are raised by the registry / target classes. + ValueError: If the target type is not registered, loads model code on + the server, or identity auth is requested but unsupported by the + target type. Construction errors (unknown params, incompatible inner + targets, unrecognized identity endpoints) are raised by the + registry / target classes. """ if request.type not in self._registry: raise ValueError( @@ -251,6 +253,11 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn ) target_cls = self._registry.get_class(request.type) + if target_cls.loads_local_code: + raise ValueError( + f"Target type '{request.type}' loads model code on the server and cannot be created through the " + "API. Register it in Python or with an initializer instead." + ) params: dict[str, Any] = dict(request.params) if request.auth_mode == "identity": diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index cf04d20e52..cd64f02fe7 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -84,6 +84,10 @@ class PromptTarget(Identifiable): # Azure Blob Storage, Prompt Shield) override this to add ``"identity"``. supported_auth_modes: ClassVar[tuple[AuthMode, ...]] = ("api_key",) + # Targets that load model code on the machine running PyRIT set ``loads_local_code``. The + # create-target API does not build them; register them in Python or with an initializer. + loads_local_code: ClassVar[bool] = False + def __init_subclass__(cls, **kwargs: object) -> None: """ Validate that subclasses follow the keyword-only ``__init__`` contract. diff --git a/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py b/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py index db37e5de81..6bc95da71d 100644 --- a/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py +++ b/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py @@ -6,7 +6,7 @@ import logging import warnings from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from pyrit.common import default_values from pyrit.exceptions import EmptyResponseException, pyrit_target_retry @@ -36,6 +36,8 @@ class HuggingFaceChatTarget(PromptTarget): ) ) + loads_local_code: ClassVar[bool] = True + # Class-level cache for model and tokenizer _cached_model: Any = None _cached_tokenizer: Any = None diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index 5b618db901..adc5316dd4 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -13,7 +13,7 @@ from pyrit.backend.models.targets import CreateTargetRequest from pyrit.backend.services.target_service import TargetService, get_target_service from pyrit.models import ComponentIdentifier -from pyrit.prompt_target import PromptTarget, TargetCapabilities +from pyrit.prompt_target import HuggingFaceChatTarget, PromptTarget, TargetCapabilities from pyrit.registry import TargetRegistry from unit.mocks import MockPromptTarget @@ -263,7 +263,6 @@ async def test_types_include_declarative_auth_facts(self) -> None: ("OpenAIChatTarget", {"endpoint", "model_name"}), ("AzureBlobStorageTarget", {"container_url"}), ("HackAPromptTarget", {"cookie", "session_id"}), - ("HuggingFaceChatTarget", {"hf_access_token"}), ("PromptShieldTarget", {"endpoint"}), ("AzureMLChatTarget", {"endpoint"}), ], @@ -417,6 +416,15 @@ async def test_types_include_enum_parameters( assert parameter.type_name == type_name assert parameter.choices == choices + async def test_types_leave_out_targets_that_load_model_code(self) -> None: + service = TargetService() + + result = await service.list_target_types_async() + + target_types = {item.target_type for item in result.items} + assert "HuggingFaceChatTarget" not in target_types + assert "OpenAIChatTarget" in target_types + class TestCreateTarget: """Tests for TargetService.create_target method.""" @@ -624,6 +632,23 @@ async def test_create_target_with_different_underlying_model(self, sqlite_instan assert result.identifier.model_name == "my-gpt4o-deployment" assert result.identifier.underlying_model_name == "gpt-4o" + async def test_create_target_rejects_target_that_loads_model_code(self, sqlite_instance) -> None: + service = TargetService() + request = CreateTargetRequest( + name="local-model", + type="HuggingFaceChatTarget", + params={"model_path": "/models/local", "trust_remote_code": True}, + ) + + with ( + patch.object(HuggingFaceChatTarget, "__init__", side_effect=AssertionError("constructed")) as init, + pytest.raises(ValueError, match="'HuggingFaceChatTarget' loads model code on the server"), + ): + await service.create_target_async(request=request) + + init.assert_not_called() + assert service.get_target_object(target_registry_name="local-model") is None + class TestCreateTargetEntraAuth: """Entra auth at the service boundary: the service only omits the api_key and From ea0bacd8aee446c4e3b2976cb2180fe99e9efc9c Mon Sep 17 00:00:00 2001 From: varunj-msft Date: Wed, 14 Oct 2026 03:38:15 +0000 Subject: [PATCH 2/3] Require administrator access to create targets that run model repository code Targets that run model repository code during construction (currently HuggingFaceChatTarget via trust_remote_code) are kept in the create-target catalog. The service now gates construction: the boolean code-execution parameter named by the target is coerced through the registry and, when it resolves true, creation requires administrator access and otherwise returns 403 before the target is built. A value the registry cannot interpret as a boolean is rejected with 400 rather than reaching the loader as a truthy non-boolean. Targets without such a parameter, and disabled or omitted values, are unaffected. --- doc/gui/0_gui.md | 5 +- pyrit/backend/middleware/auth.py | 23 ++- pyrit/backend/routes/targets.py | 17 ++- pyrit/backend/services/target_service.py | 76 +++++++--- pyrit/prompt_target/common/prompt_target.py | 8 +- .../hugging_face/hugging_face_chat_target.py | 2 +- tests/unit/backend/test_api_routes.py | 39 +++++ tests/unit/backend/test_target_service.py | 137 ++++++++++++++++++ 8 files changed, 275 insertions(+), 32 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 87b715fcd7..6292b22a8d 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -16,8 +16,9 @@ pyrit_backend Then open `http://localhost:8000` in your browser. -Authentication-disabled local servers deny administrator operations by default. To enable configuration and initializer -administration for a trusted local development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use this +Authentication-disabled local servers deny administrator operations by default. To enable administrator operations +(configuration, initializer administration, and creating targets that run model repository code) for a trusted local +development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use this setting on a network-accessible deployment. ### Docker diff --git a/pyrit/backend/middleware/auth.py b/pyrit/backend/middleware/auth.py index face0d3d3b..416e90bfe5 100644 --- a/pyrit/backend/middleware/auth.py +++ b/pyrit/backend/middleware/auth.py @@ -58,16 +58,27 @@ def authorization_environment(request: Request) -> Mapping[str, str]: return cast("Mapping[str, str]", environment) if isinstance(environment, dict) else os.environ -def require_admin(request: Request) -> None: - """Require an administrator when authentication is enabled.""" +def has_admin_access(request: Request) -> bool: + """ + Return whether the caller is an administrator. + + Without authentication, only a server that sets ``PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`` + grants administrator access. + + Returns: + bool: Whether the caller has administrator access. + """ user = getattr(request.state, "user", None) if user is None: - allow_unauthenticated = ( + return ( authorization_environment(request).get("PYRIT_ALLOW_UNAUTHENTICATED_ADMIN", "").strip().casefold() == "true" ) - if allow_unauthenticated: - return - if not isinstance(user, AuthenticatedUser) or not user.is_admin: + return isinstance(user, AuthenticatedUser) and user.is_admin + + +def require_admin(request: Request) -> None: + """Require an administrator when authentication is enabled.""" + if not has_admin_access(request): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Administrator access is required", diff --git a/pyrit/backend/routes/targets.py b/pyrit/backend/routes/targets.py index 050b4ed0c7..1cda72abb4 100644 --- a/pyrit/backend/routes/targets.py +++ b/pyrit/backend/routes/targets.py @@ -8,8 +8,9 @@ Target types are set at app startup via initializers - you cannot add new types at runtime. """ -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, HTTPException, Query, Request, status +from pyrit.backend.middleware.auth import has_admin_access from pyrit.backend.models.common import CursorStr, IdentifierStr, ProblemDetail from pyrit.backend.models.targets import ( CreateTargetRequest, @@ -72,10 +73,15 @@ async def list_target_types() -> TargetTypeResponse: # pyrit-async-suffix-exemp "model": ProblemDetail, "description": "Invalid target type or parameters", }, + 403: { + "model": ProblemDetail, + "description": "Administrator access is required to run model repository code", + }, }, ) async def create_target( - request: CreateTargetRequest, + body: CreateTargetRequest, + request: Request, ) -> TargetInstance: # pyrit-async-suffix-exempt """ Create a new target instance. @@ -91,7 +97,12 @@ async def create_target( service = get_target_service() try: - return await service.create_target_async(request=request) + return await service.create_target_async(request=body, is_admin=has_admin_access(request)) + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) from e except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py index c6c1b23688..2b8df7a66c 100644 --- a/pyrit/backend/services/target_service.py +++ b/pyrit/backend/services/target_service.py @@ -39,6 +39,7 @@ "AzureBlobStorageTarget": frozenset({"container_url"}), "AzureMLChatTarget": frozenset({"endpoint"}), "HackAPromptTarget": frozenset({"cookie", "session_id"}), + "HuggingFaceChatTarget": frozenset({"hf_access_token"}), "PromptShieldTarget": frozenset({"endpoint"}), } @@ -192,8 +193,7 @@ async def list_target_types_async(self) -> TargetTypeResponse: constructor parameters they may supply, each described in the form callers send it, and the auth modes it supports, all projected from the registry's ``TargetMetadata``; targets that need a Python object for a required - parameter, and targets that load model code on the server, are left out. - Deciding which entries to surface to a + parameter are left out. Deciding which entries to surface to a user is a presentation concern owned by the caller (e.g. the frontend), not this service. @@ -217,11 +217,10 @@ async def list_target_types_async(self) -> TargetTypeResponse: ) for metadata in metadata_items if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required) - and not self._registry.get_class(metadata.class_name).loads_local_code ] return TargetTypeResponse(items=items) - async def create_target_async(self, *, request: CreateTargetRequest) -> TargetInstance: + async def create_target_async(self, *, request: CreateTargetRequest, is_admin: bool = False) -> TargetInstance: """ Create a new target instance from API request. @@ -233,22 +232,24 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn supports it and omits the api_key plus any registry-flagged identity-conflicting parameters so the target validates its own endpoint and authenticates itself. GitHub Copilot requires an explicit - or environment token rather than SDK login discovery in CoPyRIT. - The response is built before the - target is registered, so a failed request leaves no registered target. + or environment token rather than SDK login discovery in CoPyRIT. A target + whose construction runs model repository code is built only for an + administrator. The response is built before the target is registered, so + a failed request leaves no registered target. Args: request: The create target request with type, params, and auth_mode. + is_admin: Whether the caller may create a target that runs model repository code. Returns: TargetInstance with the new target's details. Raises: - ValueError: If the target type is not registered, loads model code on - the server, or identity auth is requested but unsupported by the - target type. Construction errors (unknown params, incompatible inner - targets, unrecognized identity endpoints) are raised by the - registry / target classes. + ValueError: If the target type is not registered or identity auth is + requested but unsupported by the target type. Construction errors + (unknown params, incompatible inner targets, unrecognized identity + endpoints) are raised by the registry / target classes. + PermissionError: If a non-administrator enables a target's model code execution. """ if request.type not in self._registry: raise ValueError( @@ -256,11 +257,6 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn ) target_cls = self._registry.get_class(request.type) - if target_cls.loads_local_code: - raise ValueError( - f"Target type '{request.type}' loads model code on the server and cannot be created through the " - "API. Register it in Python or with an initializer instead." - ) params: dict[str, Any] = dict(request.params) if request.auth_mode == "identity": @@ -290,6 +286,8 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn "GITHUB_TOKEN for this CoPyRIT instance through its environment or loaded .env/.env.local files." ) + await self._enforce_code_execution_permission_async(target_type=request.type, params=params, is_admin=is_admin) + # LEGACY COMPATIBILITY: The current configuration UI omits the name. # Remove this generated fallback after that UI sends an explicit name. target_registry_name = request.name or f"compat_{uuid.uuid4().hex}" @@ -299,6 +297,50 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn self._registry.instances.register(target_obj, name=target_registry_name) return target + async def _enforce_code_execution_permission_async( + self, *, target_type: str, params: dict[str, Any], is_admin: bool + ) -> None: + """ + Require administrator access when a target's model-code-execution parameter resolves true. + + A target names the boolean parameter that runs model repository code through + ``code_execution_parameter``. The supplied value is coerced through the registry (so the + textual boolean forms the catalog accepts are honored) and written back, so the permission + check and the constructor agree and a value the registry cannot interpret as a boolean is + rejected before construction rather than reaching the loader as a truthy non-boolean. + + Args: + target_type (str): Registered target class name. + params (dict[str, Any]): Constructor parameters, updated in place with the coerced value. + is_admin (bool): Whether the caller may enable model code execution. + + Raises: + ValueError: If the parameter value cannot be coerced to a boolean, or the target names a + model-code-execution parameter that the registry does not expose (so the check would + otherwise be skipped silently). + PermissionError: If the parameter resolves true and the caller is not an administrator. + """ + parameter_name = self._registry.get_class(target_type).code_execution_parameter + if parameter_name is None: + return + metadata = await asyncio.to_thread(self._registry.get_registered_class_metadata, target_type) + parameter = next((p for p in (metadata.parameters if metadata else ()) if p.name == parameter_name), None) + if parameter is None: + raise ValueError( + f"Target type '{target_type}' names '{parameter_name}' as its model-code-execution parameter, " + "but the registry does not expose it, so the administrator requirement cannot be enforced." + ) + if parameter_name in params: + params[parameter_name] = parameter.coerce_value(params[parameter_name]) + enabled = bool(params[parameter_name]) + else: + enabled = bool(parameter.default) + if enabled and not is_admin: + raise PermissionError( + f"Creating '{target_type}' with '{parameter_name}' enabled runs model repository code and " + "requires administrator access." + ) + @lru_cache(maxsize=1) def get_target_service() -> TargetService: diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index cd64f02fe7..90863dbb83 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -84,9 +84,11 @@ class PromptTarget(Identifiable): # Azure Blob Storage, Prompt Shield) override this to add ``"identity"``. supported_auth_modes: ClassVar[tuple[AuthMode, ...]] = ("api_key",) - # Targets that load model code on the machine running PyRIT set ``loads_local_code``. The - # create-target API does not build them; register them in Python or with an initializer. - loads_local_code: ClassVar[bool] = False + # Targets whose construction can run model repository code name the boolean constructor + # parameter that enables it (e.g. ``"trust_remote_code"``). The create-target API builds + # them only for an administrator when that parameter resolves true; direct Python use is + # unaffected. + code_execution_parameter: ClassVar[str | None] = None def __init_subclass__(cls, **kwargs: object) -> None: """ diff --git a/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py b/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py index 6bc95da71d..ad52edd6ee 100644 --- a/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py +++ b/pyrit/prompt_target/hugging_face/hugging_face_chat_target.py @@ -36,7 +36,7 @@ class HuggingFaceChatTarget(PromptTarget): ) ) - loads_local_code: ClassVar[bool] = True + code_execution_parameter: ClassVar[str | None] = "trust_remote_code" # Class-level cache for model and tokenizer _cached_model: Any = None diff --git a/tests/unit/backend/test_api_routes.py b/tests/unit/backend/test_api_routes.py index c51b860df4..47151b855b 100644 --- a/tests/unit/backend/test_api_routes.py +++ b/tests/unit/backend/test_api_routes.py @@ -1260,6 +1260,45 @@ def test_create_target_internal_error(self, client: TestClient) -> None: assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + def test_create_target_forbids_code_execution_for_non_admin(self, client: TestClient) -> None: + """A PermissionError from the service maps to 403, not 400 or 500.""" + with patch("pyrit.backend.routes.targets.get_target_service") as mock_get_service: + mock_service = MagicMock() + mock_service.create_target_async = AsyncMock( + side_effect=PermissionError("Creating 'HuggingFaceChatTarget' ... requires administrator access.") + ) + mock_get_service.return_value = mock_service + + response = client.post( + "/api/targets", + json={"type": "HuggingFaceChatTarget", "params": {"trust_remote_code": True}}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "administrator access" in response.json()["detail"] + + def test_create_target_passes_admin_flag_from_request(self, client: TestClient) -> None: + """The route resolves administrator access and forwards it to the service.""" + with ( + patch("pyrit.backend.routes.targets.get_target_service") as mock_get_service, + patch("pyrit.backend.routes.targets.has_admin_access", return_value=True) as mock_has_admin, + ): + mock_service = MagicMock() + mock_service.create_target_async = AsyncMock( + return_value=TargetInstance( + target_registry_name="target-1", + identifier=TargetIdentifier(class_name="TextTarget"), + capabilities=TargetCapabilities(), + ) + ) + mock_get_service.return_value = mock_service + + response = client.post("/api/targets", json={"type": "TextTarget", "params": {}}) + + assert response.status_code == status.HTTP_201_CREATED + mock_has_admin.assert_called_once() + assert mock_service.create_target_async.call_args.kwargs["is_admin"] is True + def test_get_target_success(self, client: TestClient) -> None: """Test getting a target by ID.""" with patch("pyrit.backend.routes.targets.get_target_service") as mock_get_service: diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index 8ab52a849c..459b754f7f 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -274,6 +274,14 @@ async def test_types_include_github_copilot_form_parameters_async(self) -> None: assert not {"endpoint", "api_key", "underlying_model"} & set(parameters) assert "identity" not in entry.supported_auth_modes + async def test_types_include_hugging_face_target_async(self) -> None: + service = TargetService() + + result = await service.list_target_types_async() + + entry = next(item for item in result.items if item.target_type == "HuggingFaceChatTarget") + assert "trust_remote_code" in {parameter.name for parameter in entry.parameters} + @pytest.mark.parametrize( ("target_type", "parameter_names"), [ @@ -509,6 +517,135 @@ async def test_create_github_copilot_target_requires_token_async( assert service.get_target_object(target_registry_name="copilot") is None +@pytest.mark.usefixtures("patch_central_database") +class TestCreateTargetCodeExecution: + """Admin gate for targets whose construction runs model repository code.""" + + @staticmethod + def _request(params: dict[str, object] | None = None) -> CreateTargetRequest: + return CreateTargetRequest(name="hf", type="HuggingFaceChatTarget", params={"model_id": "m", **(params or {})}) + + @pytest.mark.parametrize( + ("params", "expected_written"), + [ + ({}, None), + ({"trust_remote_code": False}, False), + ({"trust_remote_code": "false"}, False), + ({"trust_remote_code": "0"}, False), + ({"trust_remote_code": "no"}, False), + ], + ) + async def test_remote_code_disabled_is_allowed_for_non_admin( + self, params: dict[str, object], expected_written: bool | None + ) -> None: + service = TargetService() + resolved = dict(params) + + await service._enforce_code_execution_permission_async( + target_type="HuggingFaceChatTarget", params=resolved, is_admin=False + ) + + assert resolved.get("trust_remote_code") is expected_written + + @pytest.mark.parametrize("value", [True, "true", "True", " TRUE ", "1", "yes"]) + async def test_remote_code_enabled_requires_admin(self, value: object) -> None: + service = TargetService() + + with pytest.raises(PermissionError, match="requires administrator access"): + await service._enforce_code_execution_permission_async( + target_type="HuggingFaceChatTarget", params={"trust_remote_code": value}, is_admin=False + ) + + @pytest.mark.parametrize("value", [True, "true", "1", "yes"]) + async def test_admin_may_enable_remote_code_and_value_is_coerced(self, value: object) -> None: + service = TargetService() + resolved: dict[str, object] = {"trust_remote_code": value} + + await service._enforce_code_execution_permission_async( + target_type="HuggingFaceChatTarget", params=resolved, is_admin=True + ) + + assert resolved["trust_remote_code"] is True + + @pytest.mark.parametrize("is_admin", [False, True]) + @pytest.mark.parametrize("value", [1, 2, 1.0, [True]]) + async def test_non_boolean_remote_code_is_rejected(self, value: object, is_admin: bool) -> None: + service = TargetService() + + with pytest.raises(ValueError, match="trust_remote_code"): + await service._enforce_code_execution_permission_async( + target_type="HuggingFaceChatTarget", params={"trust_remote_code": value}, is_admin=is_admin + ) + + async def test_target_without_code_execution_parameter_is_untouched(self) -> None: + service = TargetService() + resolved = {"endpoint": "https://example.test"} + + await service._enforce_code_execution_permission_async( + target_type="OpenAIChatTarget", params=resolved, is_admin=False + ) + + assert resolved == {"endpoint": "https://example.test"} + + @pytest.mark.parametrize("value", [True, "true", "1", "yes"]) + async def test_create_target_rejects_non_admin_remote_code_before_construction(self, value: object) -> None: + service = TargetService() + + with ( + patch.object(service._registry, "create_instance_from_external_input") as create_instance, + pytest.raises(PermissionError, match="requires administrator access"), + ): + await service.create_target_async(request=self._request({"trust_remote_code": value}), is_admin=False) + + create_instance.assert_not_called() + assert service.get_target_object(target_registry_name="hf") is None + + async def test_create_target_rejects_non_boolean_remote_code_before_construction(self) -> None: + service = TargetService() + + with ( + patch.object(service._registry, "create_instance_from_external_input") as create_instance, + pytest.raises(ValueError, match="trust_remote_code"), + ): + await service.create_target_async(request=self._request({"trust_remote_code": 1}), is_admin=False) + + create_instance.assert_not_called() + + async def test_create_target_without_code_execution_parameter_ignores_admin_flag(self, sqlite_instance) -> None: + service = TargetService() + + target = await service.create_target_async( + request=CreateTargetRequest(name="text", type="TextTarget", params={}), is_admin=False + ) + + assert target.identifier.class_name == "TextTarget" + + @pytest.mark.parametrize("value", [True, "true", "yes"]) + async def test_create_target_forwards_coerced_true_to_constructor_for_admin(self, value: object) -> None: + service = TargetService() + stop = RuntimeError("stop after construction") + + with ( + patch.object(service._registry, "create_instance_from_external_input", side_effect=stop) as create_instance, + pytest.raises(RuntimeError, match="stop after construction"), + ): + await service.create_target_async(request=self._request({"trust_remote_code": value}), is_admin=True) + + assert create_instance.call_args.kwargs["params"]["trust_remote_code"] is True + + async def test_declared_code_execution_parameter_missing_from_metadata_fails_closed(self) -> None: + service = TargetService() + hugging_face_cls = service._registry.get_class("HuggingFaceChatTarget") + + with ( + patch.object(hugging_face_cls, "code_execution_parameter", "nonexistent_flag"), + pytest.raises(ValueError, match="does not expose it"), + ): + await service._enforce_code_execution_permission_async( + target_type="HuggingFaceChatTarget", params={"nonexistent_flag": True}, is_admin=False + ) + + class TestCreateTarget: """Tests for TargetService.create_target method.""" From 5de27b4c9244796ec7dca190475c9746ec4269de Mon Sep 17 00:00:00 2001 From: varunj-msft Date: Wed, 14 Oct 2026 05:45:30 +0000 Subject: [PATCH 3/3] Test that create forwards the coerced model-code flag to construction --- tests/unit/backend/test_target_service.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index 459b754f7f..b3bc7f1b8d 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -633,6 +633,19 @@ async def test_create_target_forwards_coerced_true_to_constructor_for_admin(self assert create_instance.call_args.kwargs["params"]["trust_remote_code"] is True + @pytest.mark.parametrize("value", [False, "false", "no", "0"]) + async def test_create_target_forwards_coerced_false_to_constructor_for_non_admin(self, value: object) -> None: + service = TargetService() + stop = RuntimeError("stop after construction") + + with ( + patch.object(service._registry, "create_instance_from_external_input", side_effect=stop) as create_instance, + pytest.raises(RuntimeError, match="stop after construction"), + ): + await service.create_target_async(request=self._request({"trust_remote_code": value}), is_admin=False) + + assert create_instance.call_args.kwargs["params"]["trust_remote_code"] is False + async def test_declared_code_execution_parameter_missing_from_metadata_fails_closed(self) -> None: service = TargetService() hugging_face_cls = service._registry.get_class("HuggingFaceChatTarget")