From 09843db124325ede67e7ae38b1d89cda7fe74179 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 14 May 2026 12:57:34 +0530 Subject: [PATCH 1/4] UN-3393 [FEAT] Switch AuditSerializer to SanitizedSerializerMixin base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the foundation PR by routing every `AuditSerializer` subclass through `SanitizedSerializerMixin`. ~18 write-path serializers (`WorkflowSerializer`, `CustomToolSerializer`, `APIDeploymentSerializer`, `APIKeySerializer`, `ConnectorInstanceSerializer`, `BaseAdapterSerializer`, `PlatformKeySerializer`, `PlatformApiKey{Create,Update}Serializer`, `PipelineSerializer`, `ToolInstanceSerializer`, `ToolStudioPromptSerializer`, `PromptStudioOutputSerializer`, `ProfileManagerSerializer`, `IndexManagerSerializer`, `PromptStudioRegistry{,Info}Serializer`, `PromptStudioDocumentManagerSerializer`) now auto-reject HTML/JS-shaped input on every writable `CharField` / `TextField`. - `backend/backend/serializers.py`: `AuditSerializer` now inherits `utils.serializer.ModelSerializer` (the pre-mixed variant) instead of `rest_framework.serializers.ModelSerializer`. `create` / `update` semantics unchanged. - `prompt_studio/prompt_studio_v2.ToolStudioPromptSerializer`: declares `Meta.html_safe_fields = ("prompt", "assert_prompt", "assertion_failure_prompt", "output")`. LLM prompt text legitimately contains XML/HTML-like markup (e.g. ``, ``); LLM `output` may include anything the model produced. - `prompt_studio/prompt_studio_core_v2.CustomToolSerializer`: declares `Meta.html_safe_fields = ("summarize_prompt", "preamble", "postamble", "output")`. Tool-level LLM context fields and stored LLM output. - Removes redundant manual `validate_description(self, value)` methods in `api_v2.APIDeploymentSerializer`, `workflow_v2.WorkflowSerializer`, and `prompt_studio_core_v2.CustomToolSerializer`. The mixin now covers these via the `AuditSerializer` base. `validate_` methods that use `validate_name_field` are retained because they also strip whitespace and reject empty values — behaviour the mixin doesn't duplicate. - Imports of `validate_no_html_tags` are dropped from the three files whose `validate_description` methods were removed. Files that inherit DRF base classes directly (without going through `AuditSerializer`) are tracked as a follow-up sweep. The AuditSerializer path already covers the highest-value write-path entities. Test coverage: `cd backend && uv run pytest utils/tests/ -q` → 85 passed. Full Django check requires cloud-deps; PR CI exercises the full suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/api_v2/serializers.py | 7 +------ backend/backend/serializers.py | 2 +- .../prompt_studio_core_v2/serializers.py | 15 +++++++++------ .../prompt_studio/prompt_studio_v2/serializers.py | 9 +++++++++ .../workflow_manager/workflow_v2/serializers.py | 7 +------ 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 7c9a5a7696..f7992deecb 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -22,7 +22,7 @@ ValidationError, ) from tags.serializers import TagParamsSerializer -from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.input_sanitizer import validate_name_field from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from workflow_manager.endpoint_v2.models import WorkflowEndpoint from workflow_manager.workflow_v2.exceptions import ExecutionDoesNotExistError @@ -66,11 +66,6 @@ def validate_api_name(self, value: str) -> str: def validate_display_name(self, value: str) -> str: return validate_name_field(value, field_name="Display name") - def validate_description(self, value: str) -> str: - if value is None: - return value - return validate_no_html_tags(value, field_name="Description") - def validate_workflow(self, workflow): """Validate that the workflow has properly configured source and destination endpoints.""" # Get all endpoints for this workflow with related data diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py index 62a92fc8a3..1bb0ccb2a9 100644 --- a/backend/backend/serializers.py +++ b/backend/backend/serializers.py @@ -1,6 +1,6 @@ from typing import Any -from rest_framework.serializers import ModelSerializer +from utils.serializer import ModelSerializer from backend.constants import RequestKey diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index 4f10ee2aa1..4cc2ddce1e 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from utils.FileValidator import FileValidator -from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.input_sanitizer import validate_name_field from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from backend.serializers import AuditSerializer @@ -86,6 +86,14 @@ class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): class Meta: model = CustomTool fields = "__all__" + # Tool-level LLM context fields and stored LLM output; + # may legitimately contain XML/HTML-like markup. + html_safe_fields = ( + "summarize_prompt", + "preamble", + "postamble", + "output", + ) unique_error_message_map: dict[str, dict[str, str]] = { "unique_tool_name": { @@ -99,11 +107,6 @@ class Meta: def validate_tool_name(self, value: str) -> str: return validate_name_field(value, field_name="Tool name") - def validate_description(self, value: str) -> str: - if value is None: - return value - return validate_no_html_tags(value, field_name="Description") - def validate_summarize_llm_adapter(self, value): """Validate that the adapter type is LLM and is accessible to the user.""" if value is None: diff --git a/backend/prompt_studio/prompt_studio_v2/serializers.py b/backend/prompt_studio/prompt_studio_v2/serializers.py index 6a4d28032d..9bf3a30c70 100644 --- a/backend/prompt_studio/prompt_studio_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_v2/serializers.py @@ -26,6 +26,15 @@ class ToolStudioPromptSerializer(AuditSerializer): class Meta: model = ToolStudioPrompt fields = "__all__" + # LLM prompt text legitimately contains XML/HTML-like markup + # (e.g. , ). `output` is the LLM response, + # which may include any text the model produced. + html_safe_fields = ( + "prompt", + "assert_prompt", + "assertion_failure_prompt", + "output", + ) class ToolStudioIndexSerializer(serializers.Serializer): diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index ed34592958..ab4051340f 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -14,7 +14,7 @@ ) from tool_instance_v2.serializers import ToolInstanceSerializer from tool_instance_v2.tool_instance_helper import ToolInstanceHelper -from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.input_sanitizer import validate_name_field from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from backend.constants import RequestKey @@ -50,11 +50,6 @@ class Meta: def validate_workflow_name(self, value: str) -> str: return validate_name_field(value, field_name="Workflow name") - def validate_description(self, value: str) -> str: - if value is None: - return value - return validate_no_html_tags(value, field_name="Description") - def to_representation(self, instance: Workflow) -> dict[str, str]: representation: dict[str, str] = super().to_representation(instance) representation[WorkflowKey.WF_NAME] = instance.workflow_name From 55af880ba957dcf43010e18b44ae82701bb1bfd6 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 15 May 2026 16:13:15 +0530 Subject: [PATCH 2/4] UN-3393 [FEAT] Cover Tag and Notification serializers via the mixin Extends PR2 coverage to two user-write-path serializers that don't go through `AuditSerializer`: - `tags.TagSerializer` (user-create with `name` + `description`) now inherits `utils.serializer.ModelSerializer`. Sister `TagParamsSerializer` is a query-param parser with strict regex validation; left as-is. - `notification_v2.NotificationSerializer` now inherits `utils.serializer.ModelSerializer`. The model's `name`, `authorization_key`, `authorization_header`, and `url` (URLField is a CharField subclass) are auto-sanitized. `notification_type`, `authorization_type`, `platform` are ChoiceField in the serializer, not CharField; the mixin skips them. The existing manual `validate_name` keeps its uniqueness + strip-whitespace logic; the mixin's HTML check runs alongside as redundant defence. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/notification_v2/serializers.py | 3 ++- backend/tags/serializers.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/notification_v2/serializers.py b/backend/notification_v2/serializers.py index 115487c481..cfbd174bb4 100644 --- a/backend/notification_v2/serializers.py +++ b/backend/notification_v2/serializers.py @@ -1,11 +1,12 @@ from rest_framework import serializers from utils.input_sanitizer import validate_name_field +from utils.serializer import ModelSerializer from .enums import AuthorizationType, NotificationType, PlatformType from .models import Notification -class NotificationSerializer(serializers.ModelSerializer): +class NotificationSerializer(ModelSerializer): notification_type = serializers.ChoiceField(choices=NotificationType.choices()) authorization_type = serializers.ChoiceField(choices=AuthorizationType.choices()) platform = serializers.ChoiceField(choices=PlatformType.choices(), required=False) diff --git a/backend/tags/serializers.py b/backend/tags/serializers.py index a0b63dbc68..6dc6cfac98 100644 --- a/backend/tags/serializers.py +++ b/backend/tags/serializers.py @@ -3,11 +3,12 @@ from rest_framework import serializers from rest_framework.serializers import CharField, ValidationError +from utils.serializer import ModelSerializer from tags.models import Tag -class TagSerializer(serializers.ModelSerializer): +class TagSerializer(ModelSerializer): class Meta: model = Tag fields = ["id", "name", "description"] From 6ce8ea86035ec28986325578afad8a320df43486 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 15 May 2026 17:16:19 +0530 Subject: [PATCH 3/4] UN-3393 [FIX] Opt PromptStudioOutputSerializer out of html_safe_fields Greptile P1 review comment on PR #1966. `PromptStudioOutputManager` stores raw LLM responses (`output`: CharField) and document chunks (`context`: TextField) that routinely contain , , and other XML-like tags. The serializer is mounted on a ModelViewSet with no class-level HTTP-method restriction, so write paths through DRF admin / browsable API / any future write endpoint would incorrectly reject legitimate LLM output without this opt-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../prompt_studio_output_manager_v2/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py b/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py index 1c56e2323d..fa424b5e70 100644 --- a/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py @@ -16,6 +16,13 @@ class PromptStudioOutputSerializer(AuditSerializer): class Meta: model = PromptStudioOutputManager fields = "__all__" + # Stored LLM response (`output`) and document chunks (`context`) + # routinely contain , , and other XML-like + # tags from the model. The serializer is mounted on a + # ModelViewSet with no class-level method restrictions, so writes + # routed through it (DRF admin / browsable API / any future write + # endpoint) must accept arbitrary text. + html_safe_fields = ("output", "context") def to_representation(self, instance): data = super().to_representation(instance) From 00096d345384ba60fe2a408ac2d5704c363a4807 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 19 May 2026 17:34:37 +0530 Subject: [PATCH 4/4] UN-3393 [FEAT] Inline field errors on prompt card; humanise error labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mixin now emits "Prompt key …" instead of "prompt_key …" by using `field.label` or a title-cased fallback when invoking the validator. - DocumentParser re-throws DRF field-keyed validation errors so the prompt card can render them inline instead of showing only a transient toast. Non-field errors continue to surface via the existing toast. - PromptCard tracks per-field error state, keeps the typed value so users can edit-and-fix in place, and clears the error on the next edit attempt for the same field. - EditableText accepts an `error` prop, applies Ant `status="error"` on the input, and renders the message in a danger Text node below. Co-Authored-By: Claude Opus 4.7 --- backend/utils/serializer/sanitization.py | 7 ++- .../tests/test_sanitized_serializer_mixin.py | 11 +++++ .../document-parser/DocumentParser.jsx | 10 ++++ .../editable-text/EditableText.css | 6 +++ .../editable-text/EditableText.jsx | 48 +++++++++++-------- .../custom-tools/prompt-card/Header.jsx | 3 ++ .../custom-tools/prompt-card/PromptCard.jsx | 31 +++++++++++- .../prompt-card/PromptCardItems.jsx | 3 ++ 8 files changed, 97 insertions(+), 22 deletions(-) diff --git a/backend/utils/serializer/sanitization.py b/backend/utils/serializer/sanitization.py index 3c53ed73d4..70853abd9a 100644 --- a/backend/utils/serializer/sanitization.py +++ b/backend/utils/serializer/sanitization.py @@ -36,9 +36,14 @@ def __init__(self, *args, **kwargs): if name in exempt or field.read_only: continue if isinstance(field, drf.CharField): + # Prefer a user-visible label so errors read like + # "Prompt key must not contain..." instead of "prompt_key must...". + display_name = field.label or name.replace("_", " ").capitalize() # partial binds the field name at iteration time, avoiding the # late-binding closure trap of a bare lambda. - field.validators.append(partial(validate_no_html_tags, field_name=name)) + field.validators.append( + partial(validate_no_html_tags, field_name=display_name) + ) class ModelSerializer(SanitizedSerializerMixin, drf.ModelSerializer): diff --git a/backend/utils/tests/test_sanitized_serializer_mixin.py b/backend/utils/tests/test_sanitized_serializer_mixin.py index 20f4833a2a..a4ffe14d98 100644 --- a/backend/utils/tests/test_sanitized_serializer_mixin.py +++ b/backend/utils/tests/test_sanitized_serializer_mixin.py @@ -56,6 +56,17 @@ def test_each_field_gets_its_own_validator(self): msg = str(s.errors["description"][0]) assert "description" in msg.lower() + def test_error_message_uses_humanised_field_name(self): + """snake_case field names are surfaced as 'Snake case' in user-visible errors.""" + + class SnakeCaseSerializer(Serializer): + prompt_key = drf.CharField() + + s = SnakeCaseSerializer(data={"prompt_key": "

x

"}) + assert not s.is_valid() + msg = str(s.errors["prompt_key"][0]) + assert msg.startswith("Prompt key ") + def test_html_safe_fields_opts_out(self): s = WithOptOutSerializer( data={"name": "ok", "prompt": "step 1"} diff --git a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx index 63b1a037c5..d65a4d2b33 100644 --- a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx +++ b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx @@ -205,6 +205,16 @@ function DocumentParser({ return res; }) .catch((err) => { + // Field-keyed DRF validation errors are surfaced inline on the + // prompt card; re-throw so the card's catch can render them. + const data = err?.response?.data; + const hasFieldError = + data?.type === "validation_error" && + Array.isArray(data?.errors) && + data.errors.some((e) => e?.attr); + if (hasFieldError) { + throw err; + } setAlertDetails(handleException(err, "Failed to update")); }); }; diff --git a/frontend/src/components/custom-tools/editable-text/EditableText.css b/frontend/src/components/custom-tools/editable-text/EditableText.css index fa526d14b3..7850b0e860 100644 --- a/frontend/src/components/custom-tools/editable-text/EditableText.css +++ b/frontend/src/components/custom-tools/editable-text/EditableText.css @@ -20,3 +20,9 @@ font-weight: bold; font-size: 14px; } + +.editable-text-error-message { + display: block; + margin-top: 2px; + font-size: 12px; +} diff --git a/frontend/src/components/custom-tools/editable-text/EditableText.jsx b/frontend/src/components/custom-tools/editable-text/EditableText.jsx index c2bc5bcebd..7890d881b7 100644 --- a/frontend/src/components/custom-tools/editable-text/EditableText.jsx +++ b/frontend/src/components/custom-tools/editable-text/EditableText.jsx @@ -1,4 +1,4 @@ -import { Input } from "antd"; +import { Input, Typography } from "antd"; import debounce from "lodash/debounce"; import PropTypes from "prop-types"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -17,6 +17,7 @@ function EditableText({ isTextarea, placeHolder, isCoverageLoading, + error, }) { const name = isTextarea ? "prompt" : "prompt_key"; const [triggerHandleChange, setTriggerHandleChange] = useState(false); @@ -94,24 +95,32 @@ function EditableText({ } return ( - setIsHovered(true)} - onMouseOut={() => setIsHovered(false)} - onBlur={handleBlur} - onClick={() => setIsEditing(true)} - disabled={ - isCoverageLoading || isSinglePassExtractLoading || isPublicSource - } - /> + <> + setIsHovered(true)} + onMouseOut={() => setIsHovered(false)} + onBlur={handleBlur} + onClick={() => setIsEditing(true)} + disabled={ + isCoverageLoading || isSinglePassExtractLoading || isPublicSource + } + status={error ? "error" : undefined} + /> + {error && ( + + {error} + + )} + ); } @@ -126,6 +135,7 @@ EditableText.propTypes = { isTextarea: PropTypes.bool, placeHolder: PropTypes.string, isCoverageLoading: PropTypes.bool.isRequired, + error: PropTypes.string, }; export { EditableText }; diff --git a/frontend/src/components/custom-tools/prompt-card/Header.jsx b/frontend/src/components/custom-tools/prompt-card/Header.jsx index 30fe75fe54..16342f4af7 100644 --- a/frontend/src/components/custom-tools/prompt-card/Header.jsx +++ b/frontend/src/components/custom-tools/prompt-card/Header.jsx @@ -92,6 +92,7 @@ function Header({ handleSpsLoading, enforceType, isAgenticTableReady = true, + promptKeyError, }) { const { selectedDoc, @@ -335,6 +336,7 @@ function Header({ handleChange={handleChange} placeHolder={updatePlaceHolder} isCoverageLoading={isCoverageLoading} + error={promptKeyError} /> @@ -493,6 +495,7 @@ Header.propTypes = { handleSpsLoading: PropTypes.func.isRequired, enforceType: PropTypes.string, isAgenticTableReady: PropTypes.bool, + promptKeyError: PropTypes.string, }; export { Header }; diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx index 29fda260e2..5e67360e52 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx @@ -53,6 +53,7 @@ const PromptCard = memo( const [promptKey, setPromptKey] = useState(""); const [promptText, setPromptText] = useState(""); const [selectedLlmProfileId, setSelectedLlmProfileId] = useState(null); + const [fieldErrors, setFieldErrors] = useState({}); const [isCoverageLoading, setIsCoverageLoading] = useState(false); const [openOutputForDoc, setOpenOutputForDoc] = useState(false); @@ -157,6 +158,14 @@ const PromptCard = memo( const updatedPromptDetailsState = { ...promptDetailsState }; updatedPromptDetailsState[name] = value; + // New attempt — drop any prior inline error for this field. + setFieldErrors((prev) => { + if (!(name in prev)) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); + handleUpdateStatus( isUpdateStatus, promptId, @@ -178,9 +187,26 @@ const PromptCard = memo( setUpdateStatus, ); }) - .catch(() => { + .catch((err) => { handleUpdateStatus(isUpdateStatus, promptId, null, setUpdateStatus); - setPromptDetailsState(prevPromptDetailsState); + const data = err?.response?.data; + const fieldErrorMap = {}; + if ( + data?.type === "validation_error" && + Array.isArray(data?.errors) + ) { + data.errors.forEach((e) => { + if (e?.attr) { + fieldErrorMap[e.attr] = e.detail || "Invalid value"; + } + }); + } + if (Object.keys(fieldErrorMap).length > 0) { + // Keep the typed value so user can fix in place; show inline error. + setFieldErrors((prev) => ({ ...prev, ...fieldErrorMap })); + } else { + setPromptDetailsState(prevPromptDetailsState); + } }) .finally(() => { if (isUpdateStatus) { @@ -381,6 +407,7 @@ const PromptCard = memo( coverageCountData={coverageCountData} isChallenge={isChallenge} handleSelectHighlight={handleSelectHighlight} + fieldErrors={fieldErrors} /> @@ -369,6 +371,7 @@ function PromptCardItems({ } PromptCardItems.propTypes = { + fieldErrors: PropTypes.object, promptDetails: PropTypes.object.isRequired, enforceTypeList: PropTypes.array, allTableSettings: PropTypes.array,