From d5fecca5c21f0ae30c56eed8a7ef407107e5c4c1 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Sun, 1 Nov 2026 05:14:50 -0500 Subject: [PATCH] fix: report no auth modes for credential-free TextTarget TextTarget writes to a local stream and never contacts a service, but it inherited the default supported_auth_modes of (api_key,), so the target picker described it as 'Supported authentication: API key'. Declare an empty tuple and have the dialog say 'No authentication required' when the registry reports no auth modes. --- .../Config/CreateTargetDialog.test.tsx | 23 ++++++++++++++++++- .../components/Config/CreateTargetDialog.tsx | 11 +++++---- pyrit/prompt_target/text_target.py | 6 ++++- tests/unit/backend/test_target_service.py | 9 ++++++++ tests/unit/registry/test_target_registry.py | 6 ++++- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx index 056405e25a..1615068adc 100644 --- a/frontend/src/components/Config/CreateTargetDialog.test.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx @@ -273,6 +273,12 @@ const TARGET_TYPES: TargetTypeListResponse = { supported_auth_modes: ["api_key"], description: "A prompt target that distributes requests across multiple inner targets using weighted round-robin selection.", }, + { + target_type: "TextTarget", + parameters: [], + supported_auth_modes: [], + description: "Writes prompts to a local text stream.", + }, { target_type: "HTTPTarget", parameters: [ @@ -346,6 +352,7 @@ const TARGET_DISPLAY_NAMES: Record = { OpenAIVideoTarget: "OpenAI video", RoundRobinTarget: "Weighted round robin", HTTPTarget: "HTTPTarget", + TextTarget: "TextTarget", AzureBlobStorageTarget: "AzureBlobStorageTarget", }; @@ -542,7 +549,7 @@ describe("CreateTargetDialog", () => { await openTargetTypePicker(); const options = screen.getAllByRole("option"); - expect(options).toHaveLength(11); + expect(options).toHaveLength(12); for (const entry of TARGET_TYPES.items.filter((item) => item.target_type !== "PlaywrightTarget")) { const option = screen.getByRole("option", { name: new RegExp(`Implementation: ${entry.target_type}`), @@ -583,6 +590,20 @@ describe("CreateTargetDialog", () => { expect(selectedDetails).toHaveTextContent("Supported authentication: API key"); }); + it("should say a credential-free target needs no authentication", async () => { + render( + + + + ); + + await selectTargetType("TextTarget"); + + const selectedDetails = screen.getByRole("region", { name: /selected target details/i }); + expect(selectedDetails).toHaveTextContent("No authentication required"); + expect(selectedDetails).not.toHaveTextContent("API key"); + }); + it("should keep the selected target displayed after focus moves to another field", async () => { const user = userEvent.setup(); diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx index 6f30edefbd..4cd4aae3a2 100644 --- a/frontend/src/components/Config/CreateTargetDialog.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.tsx @@ -129,12 +129,15 @@ function getTargetDisplayName(targetType: string): string { return TARGET_DISPLAY_NAMES[targetType] ?? targetType } -function getAuthDescription(entry: TargetTypeEntry): string | null { +function getAuthDescription(entry: TargetTypeEntry, registryMetadataLoaded: boolean): string | null { if (entry.target_type === 'GitHubCopilotTarget') { return 'Supported authentication: GitHub token' } const authModes = entry.supported_auth_modes - if (authModes.length === 0) return null + if (authModes.length === 0) { + // The built-in fallback entries also carry an empty list, so only registry data can say "no credentials". + return registryMetadataLoaded ? 'No authentication required' : null + } const labels = authModes.map((mode) => ( mode === 'identity' ? 'Microsoft Entra ID' : 'API key' @@ -379,7 +382,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT const hasApiKeyField = hasField('api_key') const selectedTargetDisplayName = getTargetDisplayName(targetType) const selectedTargetAuthDescription = targetTypeEntry - ? getAuthDescription(targetTypeEntry) + ? getAuthDescription(targetTypeEntry, typeMetadataAvailable) : null const supportsIdentity = targetTypeEntry ? targetTypeEntry.supported_auth_modes.includes('identity') @@ -669,7 +672,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT > {targetTypeOptions.map((entry) => { const displayName = getTargetDisplayName(entry.target_type) - const authDescription = getAuthDescription(entry) + const authDescription = getAuthDescription(entry, typeMetadataAvailable) const accessibleDescription = [ displayName, entry.description, diff --git a/pyrit/prompt_target/text_target.py b/pyrit/prompt_target/text_target.py index 15533c9599..e71d261d44 100644 --- a/pyrit/prompt_target/text_target.py +++ b/pyrit/prompt_target/text_target.py @@ -2,8 +2,9 @@ # Licensed under the MIT license. import sys -from typing import IO +from typing import IO, ClassVar +from pyrit.common.auth_mode import AuthMode from pyrit.models import Message from pyrit.prompt_target.common.prompt_target import PromptTarget from pyrit.prompt_target.common.target_configuration import TargetConfiguration @@ -18,6 +19,9 @@ class TextTarget(PromptTarget): but enter them manually. """ + # Writes to a local stream and never contacts a service, so it takes no credentials. + supported_auth_modes: ClassVar[tuple[AuthMode, ...]] = () + def __init__( self, *, diff --git a/tests/unit/backend/test_target_service.py b/tests/unit/backend/test_target_service.py index 5a17eb04f7..da18534972 100644 --- a/tests/unit/backend/test_target_service.py +++ b/tests/unit/backend/test_target_service.py @@ -259,6 +259,15 @@ async def test_types_include_declarative_auth_facts(self) -> None: assert "api_key" in openai_entry.supported_auth_modes assert "identity" in openai_entry.supported_auth_modes + async def test_types_report_no_auth_modes_for_credential_free_target(self) -> None: + """TextTarget needs no credentials, so its type entry advertises no auth modes.""" + service = TargetService() + + result = await service.list_target_types_async() + + entry = next(item for item in result.items if item.target_type == "TextTarget") + assert entry.supported_auth_modes == [] + async def test_types_include_github_copilot_form_parameters_async(self) -> None: service = TargetService() diff --git a/tests/unit/registry/test_target_registry.py b/tests/unit/registry/test_target_registry.py index ffc0fbe1b7..4a719c13ed 100644 --- a/tests/unit/registry/test_target_registry.py +++ b/tests/unit/registry/test_target_registry.py @@ -362,7 +362,11 @@ def test_round_robin_targets_param_is_reference(self, registry: TargetRegistry): def test_metadata_supported_auth_modes_projects_class_attribute(self, registry: TargetRegistry): # Identity-capable targets expose both modes; api-key-only targets expose one. assert self._metadata_for(registry, "OpenAIChatTarget").supported_auth_modes == ("api_key", "identity") - assert self._metadata_for(registry, "TextTarget").supported_auth_modes == ("api_key",) + assert self._metadata_for(registry, "GitHubCopilotTarget").supported_auth_modes == ("api_key",) + + def test_metadata_supported_auth_modes_empty_for_credential_free_target(self, registry: TargetRegistry): + # TextTarget writes to a local stream, so it must not advertise api-key auth. + assert self._metadata_for(registry, "TextTarget").supported_auth_modes == () def test_metadata_supported_auth_modes_sourced_from_class_attributes(self, registry: TargetRegistry): # Auth modes are read off the class via Param.ClassAttr, not a fabricated instance.