From c617f92e00c07266b00acc712eb77170b2d1ffac Mon Sep 17 00:00:00 2001 From: justin212407 Date: Sat, 16 May 2026 03:42:42 +0530 Subject: [PATCH 1/9] fix: fix revision commit endpoints silently discard unknown fields in data Signed-off-by: justin212407 --- api/oss/src/core/workflows/dtos.py | 7 ++++- ...test_application_variants_and_revisions.py | 29 +++++++++++++++++++ sdks/python/agenta/sdk/models/workflows.py | 2 ++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index 0d8142f1c7..e59bbcc3ae 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -3,6 +3,7 @@ from pydantic import ( BaseModel, + ConfigDict, Field, ) @@ -54,9 +55,13 @@ WorkflowServiceStreamResponse, # noqa: F401 # JsonSchemas, # noqa: F401 - WorkflowRevisionData, + WorkflowRevisionData as BaseWorkflowRevisionData, ) + +class WorkflowRevisionData(BaseWorkflowRevisionData): + model_config = ConfigDict(extra="forbid") + # aliases ---------------------------------------------------------------------- diff --git a/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py b/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py index b3737b05fb..e52e9d83b0 100644 --- a/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py +++ b/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py @@ -177,6 +177,35 @@ def test_commit_application_revision_generates_slug_when_missing(self, authed_ap == body["application_revision"]["id"] ) + def test_commit_application_revision_rejects_unknown_data_field(self, authed_api): + """Issue #4315: before fix unknown data was dropped silently; now 422 on ag_config.""" + application, variant = _create_application_with_variant( + authed_api, marker=uuid4().hex[:8] + ) + + response = authed_api( + "POST", + "/applications/revisions/commit", + json={ + "application_revision_commit": { + "application_id": application["id"], + "application_variant_id": variant["id"], + "data": { + "ag_config": { + "prompt": { + "messages": [ + {"role": "system", "content": "hi"} + ] + } + } + }, + } + }, + ) + + assert response.status_code == 422 + assert "ag_config" in str(response.json()["detail"]) + def test_query_applications_excludes_evaluators_by_default(self, authed_api): marker = uuid4().hex[:8] application, _variant = _create_application_with_variant( diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index 67dfc3ac21..086c4ece93 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -119,6 +119,8 @@ class WorkflowQueryFlags(BaseModel): class WorkflowRevisionData(BaseModel): + model_config = ConfigDict(extra="forbid") + uri: Optional[str] = None url: Optional[str] = None From b25f60228730795a7f8852f3ab1d4464bcadf2ae Mon Sep 17 00:00:00 2001 From: justin212407 Date: Sun, 24 May 2026 19:43:09 +0530 Subject: [PATCH 2/9] fix: forbid unknown fields in revision commit payloads Signed-off-by: justin212407 --- api/oss/src/core/queries/dtos.py | 4 ++- api/oss/src/core/testsets/dtos.py | 4 ++- ...test_application_variants_and_revisions.py | 29 ------------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/api/oss/src/core/queries/dtos.py b/api/oss/src/core/queries/dtos.py index c467f488bc..d8c35b6300 100644 --- a/api/oss/src/core/queries/dtos.py +++ b/api/oss/src/core/queries/dtos.py @@ -1,7 +1,7 @@ from typing import List, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from oss.src.core.tracing.dtos import Filtering, Formatting @@ -119,6 +119,8 @@ class QueryVariantQuery(VariantQuery): class QueryRevisionData(BaseModel): + model_config = ConfigDict(extra="forbid") + formatting: Optional[Formatting] = None filtering: Optional[Filtering] = None windowing: Optional[Windowing] = None diff --git a/api/oss/src/core/testsets/dtos.py b/api/oss/src/core/testsets/dtos.py index a8697d70a2..c81ec5921b 100644 --- a/api/oss/src/core/testsets/dtos.py +++ b/api/oss/src/core/testsets/dtos.py @@ -1,7 +1,7 @@ from typing import Optional, List, Tuple from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from oss.src.core.shared.dtos import ( sync_alias, @@ -134,6 +134,8 @@ class TestsetVariantQuery(VariantQuery): class TestsetRevisionData(BaseModel): + model_config = ConfigDict(extra="forbid") + testcase_ids: Optional[List[UUID]] = None testcases: Optional[List[Testcase]] = None diff --git a/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py b/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py index e52e9d83b0..b3737b05fb 100644 --- a/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py +++ b/api/oss/tests/pytest/acceptance/applications/test_application_variants_and_revisions.py @@ -177,35 +177,6 @@ def test_commit_application_revision_generates_slug_when_missing(self, authed_ap == body["application_revision"]["id"] ) - def test_commit_application_revision_rejects_unknown_data_field(self, authed_api): - """Issue #4315: before fix unknown data was dropped silently; now 422 on ag_config.""" - application, variant = _create_application_with_variant( - authed_api, marker=uuid4().hex[:8] - ) - - response = authed_api( - "POST", - "/applications/revisions/commit", - json={ - "application_revision_commit": { - "application_id": application["id"], - "application_variant_id": variant["id"], - "data": { - "ag_config": { - "prompt": { - "messages": [ - {"role": "system", "content": "hi"} - ] - } - } - }, - } - }, - ) - - assert response.status_code == 422 - assert "ag_config" in str(response.json()["detail"]) - def test_query_applications_excludes_evaluators_by_default(self, authed_api): marker = uuid4().hex[:8] application, _variant = _create_application_with_variant( From 41777ce8fc84187f7fdfd8a18989bd4b3ed15fb9 Mon Sep 17 00:00:00 2001 From: justin212407 Date: Mon, 25 May 2026 15:54:28 +0530 Subject: [PATCH 3/9] fix: fix embeds resolution risk Signed-off-by: justin212407 --- api/oss/src/core/workflows/dtos.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index 49e56dc684..2875309c2a 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -60,6 +60,7 @@ class WorkflowRevisionData(BaseWorkflowRevisionData): +class WorkflowRevisionCommitData(BaseWorkflowRevisionData): model_config = ConfigDict(extra="forbid") # aliases ---------------------------------------------------------------------- @@ -296,7 +297,7 @@ class WorkflowRevisionCommit( ): flags: Optional[WorkflowFlags] = None - data: Optional[WorkflowRevisionData] = None + data: Optional[WorkflowRevisionCommitData] = None def model_post_init(self, __context) -> None: sync_alias("workflow_id", "artifact_id", self) From 328046d9154ae55cdebb25c6f9a5a28c75a0d205 Mon Sep 17 00:00:00 2001 From: justin212407 Date: Mon, 25 May 2026 17:51:13 +0530 Subject: [PATCH 4/9] fix:fix parse error Signed-off-by: justin212407 --- api/oss/src/core/workflows/dtos.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index 2875309c2a..a829cde0d4 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -59,10 +59,10 @@ ) -class WorkflowRevisionData(BaseWorkflowRevisionData): class WorkflowRevisionCommitData(BaseWorkflowRevisionData): model_config = ConfigDict(extra="forbid") + # aliases ---------------------------------------------------------------------- @@ -261,7 +261,7 @@ class WorkflowRevision( ): flags: Optional[WorkflowRevisionFlags] = None - data: Optional[WorkflowRevisionData] = None + data: Optional[BaseWorkflowRevisionData] = None def model_post_init(self, __context) -> None: sync_alias("workflow_id", "artifact_id", self) @@ -322,7 +322,7 @@ def model_post_init(self, __context) -> None: class WorkflowRevisionFork(RevisionFork): flags: Optional[WorkflowRevisionFlags] = None - data: Optional[WorkflowRevisionData] = None + data: Optional[BaseWorkflowRevisionData] = None class WorkflowRevisionForkAlias(AliasConfig): @@ -376,7 +376,7 @@ class SimpleWorkflowQueryFlags(WorkflowRevisionQueryFlags): pass -class SimpleWorkflowData(WorkflowRevisionData): +class SimpleWorkflowData(BaseWorkflowRevisionData): pass @@ -446,7 +446,7 @@ class WorkflowCatalogTemplate(WorkflowCatalogMappingMixin, Header): categories: Optional[list[str]] = None flags: Optional[WorkflowCatalogFlags] = None - data: Optional[WorkflowRevisionData] = None + data: Optional[BaseWorkflowRevisionData] = None class WorkflowCatalogPreset(WorkflowCatalogMappingMixin, Header): @@ -455,7 +455,7 @@ class WorkflowCatalogPreset(WorkflowCatalogMappingMixin, Header): categories: Optional[list[str]] = None flags: Optional[WorkflowCatalogFlags] = None - data: Optional[WorkflowRevisionData] = None + data: Optional[BaseWorkflowRevisionData] = None # ------------------------------------------------------------------------------ From f35b4b7d2161711aeb6294283ead81ccdb0d1955 Mon Sep 17 00:00:00 2001 From: justin212407 Date: Mon, 25 May 2026 18:04:03 +0530 Subject: [PATCH 5/9] fix: reinstate WorkflowRevisionData Signed-off-by: justin212407 --- api/oss/src/core/workflows/dtos.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index a829cde0d4..21df85be63 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -58,6 +58,8 @@ WorkflowRevisionData as BaseWorkflowRevisionData, ) +class WorkflowRevisionData(BaseWorkflowRevisionData): + pass class WorkflowRevisionCommitData(BaseWorkflowRevisionData): model_config = ConfigDict(extra="forbid") @@ -261,7 +263,7 @@ class WorkflowRevision( ): flags: Optional[WorkflowRevisionFlags] = None - data: Optional[BaseWorkflowRevisionData] = None + data: Optional[WorkflowRevisionData] = None def model_post_init(self, __context) -> None: sync_alias("workflow_id", "artifact_id", self) @@ -322,7 +324,7 @@ def model_post_init(self, __context) -> None: class WorkflowRevisionFork(RevisionFork): flags: Optional[WorkflowRevisionFlags] = None - data: Optional[BaseWorkflowRevisionData] = None + data: Optional[WorkflowRevisionData] = None class WorkflowRevisionForkAlias(AliasConfig): @@ -376,7 +378,7 @@ class SimpleWorkflowQueryFlags(WorkflowRevisionQueryFlags): pass -class SimpleWorkflowData(BaseWorkflowRevisionData): +class SimpleWorkflowData(WorkflowRevisionData): pass @@ -446,7 +448,7 @@ class WorkflowCatalogTemplate(WorkflowCatalogMappingMixin, Header): categories: Optional[list[str]] = None flags: Optional[WorkflowCatalogFlags] = None - data: Optional[BaseWorkflowRevisionData] = None + data: Optional[WorkflowRevisionData] = None class WorkflowCatalogPreset(WorkflowCatalogMappingMixin, Header): @@ -455,7 +457,7 @@ class WorkflowCatalogPreset(WorkflowCatalogMappingMixin, Header): categories: Optional[list[str]] = None flags: Optional[WorkflowCatalogFlags] = None - data: Optional[BaseWorkflowRevisionData] = None + data: Optional[WorkflowRevisionData] = None # ------------------------------------------------------------------------------ From 88121d6a881e42211b6a3c0cfba33b38f24c58fe Mon Sep 17 00:00:00 2001 From: justin212407 Date: Mon, 25 May 2026 18:10:57 +0530 Subject: [PATCH 6/9] fix: fix failing test Signed-off-by: justin212407 --- api/oss/src/core/workflows/dtos.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index 21df85be63..05fa11bc55 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -58,9 +58,14 @@ WorkflowRevisionData as BaseWorkflowRevisionData, ) + class WorkflowRevisionData(BaseWorkflowRevisionData): pass + +WorkflowRevisionCommitData = WorkflowRevisionData + + class WorkflowRevisionCommitData(BaseWorkflowRevisionData): model_config = ConfigDict(extra="forbid") @@ -299,7 +304,7 @@ class WorkflowRevisionCommit( ): flags: Optional[WorkflowFlags] = None - data: Optional[WorkflowRevisionCommitData] = None + data: Optional[WorkflowRevisionData] = None def model_post_init(self, __context) -> None: sync_alias("workflow_id", "artifact_id", self) From cc4db0e518060e28d94a3568bd563d17a6087650 Mon Sep 17 00:00:00 2001 From: justin212407 Date: Mon, 25 May 2026 18:16:12 +0530 Subject: [PATCH 7/9] fix: remove duplicate class Signed-off-by: justin212407 --- api/oss/src/core/workflows/dtos.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index 05fa11bc55..b7482185a6 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -66,10 +66,6 @@ class WorkflowRevisionData(BaseWorkflowRevisionData): WorkflowRevisionCommitData = WorkflowRevisionData -class WorkflowRevisionCommitData(BaseWorkflowRevisionData): - model_config = ConfigDict(extra="forbid") - - # aliases ---------------------------------------------------------------------- From d9a8549a1c157f3f40fb839af1d88ebf44e83e75 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Mon, 25 May 2026 16:26:56 +0200 Subject: [PATCH 8/9] Fix CR comments --- api/oss/src/core/embeds/service.py | 39 --- api/oss/src/core/environments/dtos.py | 4 +- .../test_revision_commit_extra_forbid.py | 317 ++++++++++++++++++ sdks/python/agenta/sdk/models/testsets.py | 4 +- .../unit/test_revision_data_extra_forbid.py | 36 ++ 5 files changed, 359 insertions(+), 41 deletions(-) create mode 100644 api/oss/tests/pytest/acceptance/test_revision_commit_extra_forbid.py create mode 100644 sdks/python/oss/tests/pytest/unit/test_revision_data_extra_forbid.py diff --git a/api/oss/src/core/embeds/service.py b/api/oss/src/core/embeds/service.py index 7601802098..8a04fecc53 100644 --- a/api/oss/src/core/embeds/service.py +++ b/api/oss/src/core/embeds/service.py @@ -16,43 +16,6 @@ log = get_module_logger(__name__) -def _normalize_configuration_for_legacy_paths( - configuration: Dict[str, Any], -) -> Dict[str, Any]: - """ - Normalize config shape so embed path extraction works across old/new payloads. - - Some revisions store prompt config under: - - parameters.prompt... - while others use: - - configuration.parameters.prompt... - - The resolver may receive embed paths targeting either shape, so we expose both. - """ - normalized = dict(configuration or {}) - - parameters = normalized.get("parameters") - config_obj = normalized.get("configuration") - - # If only legacy configuration.parameters exists, expose top-level parameters. - if not isinstance(parameters, dict) and isinstance(config_obj, dict): - cfg_params = config_obj.get("parameters") - if isinstance(cfg_params, dict): - normalized["parameters"] = cfg_params - parameters = cfg_params - - # If only top-level parameters exists, expose configuration.parameters too. - if isinstance(parameters, dict): - if not isinstance(config_obj, dict): - config_obj = {} - if not isinstance(config_obj.get("parameters"), dict): - config_obj = dict(config_obj) - config_obj["parameters"] = parameters - normalized["configuration"] = config_obj - - return normalized - - class EmbedsService: """ Centralized service for resolving embedded references. @@ -101,8 +64,6 @@ async def resolve_configuration( Returns: Tuple of (resolved configuration dict, ResolutionInfo metadata) """ - configuration = _normalize_configuration_for_legacy_paths(configuration) - # Create universal resolver with all available services resolver_callback = create_universal_resolver( project_id=project_id, diff --git a/api/oss/src/core/environments/dtos.py b/api/oss/src/core/environments/dtos.py index c24114b22b..5c92065181 100644 --- a/api/oss/src/core/environments/dtos.py +++ b/api/oss/src/core/environments/dtos.py @@ -1,7 +1,7 @@ from typing import Optional, Dict, List from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from oss.src.core.shared.dtos import ( sync_alias, @@ -128,6 +128,8 @@ class EnvironmentRevisionData(BaseModel): } """ + model_config = ConfigDict(extra="forbid") + references: Optional[Dict[str, Dict[str, Reference]]] = None diff --git a/api/oss/tests/pytest/acceptance/test_revision_commit_extra_forbid.py b/api/oss/tests/pytest/acceptance/test_revision_commit_extra_forbid.py new file mode 100644 index 0000000000..b9c1402871 --- /dev/null +++ b/api/oss/tests/pytest/acceptance/test_revision_commit_extra_forbid.py @@ -0,0 +1,317 @@ +"""Issue #4315 — revision commit endpoints reject unknown top-level `data` keys. + +Every revision-commit endpoint shares the same vulnerability class: an unknown +top-level field inside `data` used to be silently dropped, returning HTTP 200 +with `count: 1` but storing `data: {}`. Setting `extra="forbid"` on each +RevisionData DTO converts that into a 422 with the offending field named. + +These tests guard all six domains uniformly so future scope changes don't +silently regress. +""" + +from uuid import uuid4 + + +# helpers ---------------------------------------------------------------------- + + +def _commit(authed_api, path: str, payload: dict): + """POST and return the response without asserting status.""" + return authed_api("POST", path, json=payload) + + +def _assert_422_names(response, field: str): + assert response.status_code == 422, response.text + assert field in response.text + + +# workflows -------------------------------------------------------------------- + + +def _create_workflow_with_variant(authed_api): + slug = f"wf-{uuid4().hex[:8]}" + response = authed_api( + "POST", + "/workflows/", + json={"workflow": {"slug": slug, "name": slug}}, + ) + assert response.status_code == 200, response.text + workflow = response.json()["workflow"] + + variant_slug = f"{slug}-v" + response = authed_api( + "POST", + "/workflows/variants/", + json={ + "workflow_variant": { + "slug": variant_slug, + "name": variant_slug, + "workflow_id": workflow["id"], + } + }, + ) + assert response.status_code == 200, response.text + return workflow, response.json()["workflow_variant"] + + +def test_commit_workflow_revision_rejects_unknown_data_field(authed_api): + workflow, variant = _create_workflow_with_variant(authed_api) + response = _commit( + authed_api, + "/workflows/revisions/commit", + { + "workflow_revision_commit": { + "slug": uuid4().hex[-12:], + "workflow_id": workflow["id"], + "workflow_variant_id": variant["id"], + "data": {"ag_config": {"prompt": {}}}, + } + }, + ) + _assert_422_names(response, "ag_config") + + +# applications ----------------------------------------------------------------- + + +def _create_application_with_variant(authed_api): + slug = f"app-{uuid4().hex[:8]}" + response = authed_api( + "POST", + "/applications/", + json={ + "application": { + "slug": slug, + "name": slug, + "flags": { + "is_application": True, + "is_evaluator": False, + "is_snippet": False, + }, + } + }, + ) + assert response.status_code == 200, response.text + application = response.json()["application"] + + variant_slug = f"{slug}-v" + response = authed_api( + "POST", + "/applications/variants/", + json={ + "application_variant": { + "slug": variant_slug, + "name": variant_slug, + "flags": { + "is_application": True, + "is_evaluator": False, + "is_snippet": False, + }, + "application_id": application["id"], + } + }, + ) + assert response.status_code == 200, response.text + return application, response.json()["application_variant"] + + +def test_commit_application_revision_rejects_unknown_data_field(authed_api): + application, variant = _create_application_with_variant(authed_api) + response = _commit( + authed_api, + "/applications/revisions/commit", + { + "application_revision_commit": { + "application_id": application["id"], + "application_variant_id": variant["id"], + "data": {"ag_config": {"prompt": {}}}, + } + }, + ) + _assert_422_names(response, "ag_config") + + +# evaluators ------------------------------------------------------------------- + + +def _create_evaluator_with_variant(authed_api): + slug = f"ev-{uuid4().hex[:8]}" + response = authed_api( + "POST", + "/evaluators/", + json={ + "evaluator": { + "slug": slug, + "name": slug, + "flags": { + "is_application": False, + "is_evaluator": True, + "is_snippet": False, + }, + } + }, + ) + assert response.status_code == 200, response.text + evaluator = response.json()["evaluator"] + + variant_slug = f"{slug}-v" + response = authed_api( + "POST", + "/evaluators/variants/", + json={ + "evaluator_variant": { + "slug": variant_slug, + "name": variant_slug, + "flags": { + "is_application": False, + "is_evaluator": True, + "is_snippet": False, + }, + "evaluator_id": evaluator["id"], + } + }, + ) + assert response.status_code == 200, response.text + return evaluator, response.json()["evaluator_variant"] + + +def test_commit_evaluator_revision_rejects_unknown_data_field(authed_api): + evaluator, variant = _create_evaluator_with_variant(authed_api) + response = _commit( + authed_api, + "/evaluators/revisions/commit", + { + "evaluator_revision_commit": { + "evaluator_id": evaluator["id"], + "evaluator_variant_id": variant["id"], + "data": {"ag_config": {"prompt": {}}}, + } + }, + ) + _assert_422_names(response, "ag_config") + + +# testsets --------------------------------------------------------------------- + + +def _create_testset_with_variant(authed_api): + slug = f"ts-{uuid4().hex[:8]}" + response = authed_api( + "POST", + "/testsets/", + json={"testset": {"slug": slug, "name": slug}}, + ) + assert response.status_code == 200, response.text + testset = response.json()["testset"] + + variant_slug = f"{slug}-v" + response = authed_api( + "POST", + "/testsets/variants/", + json={ + "testset_variant": { + "slug": variant_slug, + "name": variant_slug, + "testset_id": testset["id"], + } + }, + ) + assert response.status_code == 200, response.text + return testset, response.json()["testset_variant"] + + +def test_commit_testset_revision_rejects_unknown_data_field(authed_api): + testset, variant = _create_testset_with_variant(authed_api) + response = _commit( + authed_api, + "/testsets/revisions/commit", + { + "testset_revision_commit": { + "slug": uuid4().hex[-12:], + "testset_id": testset["id"], + "testset_variant_id": variant["id"], + "data": {"csvdata": [{"input": "x"}]}, + } + }, + ) + _assert_422_names(response, "csvdata") + + +# queries ---------------------------------------------------------------------- + + +def test_commit_query_revision_rejects_unknown_data_field(authed_api): + slug = uuid4().hex + response = authed_api( + "POST", + "/simple/queries/", + json={ + "query": { + "slug": slug, + "name": f"Test Query {slug}", + "data": {"windowing": {"limit": 50}}, + } + }, + ) + assert response.status_code == 200, response.text + query = response.json()["query"] + + response = _commit( + authed_api, + "/queries/revisions/commit", + { + "query_revision_commit": { + "slug": uuid4().hex[-12:], + "query_id": query["id"], + "query_variant_id": query["variant_id"], + "data": {"surprise": True}, + } + }, + ) + _assert_422_names(response, "surprise") + + +# environments ----------------------------------------------------------------- + + +def _create_environment_with_variant(authed_api): + slug = f"env-{uuid4().hex[:8]}" + response = authed_api( + "POST", + "/environments/", + json={"environment": {"slug": slug, "name": slug}}, + ) + assert response.status_code == 200, response.text + environment = response.json()["environment"] + + variant_slug = f"{slug}-v" + response = authed_api( + "POST", + "/environments/variants/", + json={ + "environment_variant": { + "slug": variant_slug, + "name": variant_slug, + "environment_id": environment["id"], + } + }, + ) + assert response.status_code == 200, response.text + return environment, response.json()["environment_variant"] + + +def test_commit_environment_revision_rejects_unknown_data_field(authed_api): + environment, variant = _create_environment_with_variant(authed_api) + response = _commit( + authed_api, + "/environments/revisions/commit", + { + "environment_revision_commit": { + "slug": uuid4().hex[-12:], + "environment_id": environment["id"], + "environment_variant_id": variant["id"], + "data": {"surprise": {}}, + } + }, + ) + _assert_422_names(response, "surprise") diff --git a/sdks/python/agenta/sdk/models/testsets.py b/sdks/python/agenta/sdk/models/testsets.py index c3e4eb1b64..625707f178 100644 --- a/sdks/python/agenta/sdk/models/testsets.py +++ b/sdks/python/agenta/sdk/models/testsets.py @@ -1,7 +1,7 @@ from typing import List, Optional, Dict, Any from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from agenta.sdk.models.shared import ( Identifier, @@ -53,6 +53,8 @@ class TestsetFlags(BaseModel): class TestsetRevisionData(BaseModel): + model_config = ConfigDict(extra="forbid") + testcase_ids: Optional[List[UUID]] = None testcases: Optional[List[Testcase]] = None diff --git a/sdks/python/oss/tests/pytest/unit/test_revision_data_extra_forbid.py b/sdks/python/oss/tests/pytest/unit/test_revision_data_extra_forbid.py new file mode 100644 index 0000000000..9f8159b162 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_revision_data_extra_forbid.py @@ -0,0 +1,36 @@ +"""SDK-side strict validation for *RevisionData DTOs (issue #4315). + +These tests guard the source-of-truth Pydantic config — unknown top-level +fields in `data` payloads must raise instead of being silently dropped. +""" + +import pytest +from pydantic import ValidationError + +from agenta.sdk.models.workflows import WorkflowRevisionData +from agenta.sdk.models.testsets import TestsetRevisionData + + +class TestWorkflowRevisionDataStrict: + def test_known_field_accepted(self): + WorkflowRevisionData(uri="agenta:custom:llm:v0") + + def test_unknown_field_rejected(self): + with pytest.raises(ValidationError) as exc_info: + WorkflowRevisionData(ag_config={"prompt": {"messages": []}}) + assert "ag_config" in str(exc_info.value) + + def test_unknown_field_alongside_known_field_rejected(self): + with pytest.raises(ValidationError) as exc_info: + WorkflowRevisionData(uri="agenta:custom:llm:v0", surprise=True) + assert "surprise" in str(exc_info.value) + + +class TestTestsetRevisionDataStrict: + def test_known_field_accepted(self): + TestsetRevisionData(testcase_ids=[]) + + def test_unknown_field_rejected(self): + with pytest.raises(ValidationError) as exc_info: + TestsetRevisionData(csvdata=[{"input": "x"}]) + assert "csvdata" in str(exc_info.value) From 6615adf3388057fd2420dba4d2be1f2104535879 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Mon, 25 May 2026 16:38:36 +0200 Subject: [PATCH 9/9] fix: explicit extra=forbid on Application/Evaluator RevisionData Although Pydantic v2 inherits model_config from WorkflowRevisionData, declaring extra="forbid" explicitly on ApplicationRevisionData and EvaluatorRevisionData (both API and SDK) makes the strict-validation contract grep-friendly and matches the per-domain expectation in #4315. Also drop the now-unused ConfigDict import from workflows/dtos.py; WorkflowRevisionData inherits its strict config from the SDK base. Picked up from PR #4415. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/oss/src/core/applications/dtos.py | 4 ++-- api/oss/src/core/evaluators/dtos.py | 4 ++-- api/oss/src/core/workflows/dtos.py | 1 - sdks/python/agenta/sdk/models/workflows.py | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/api/oss/src/core/applications/dtos.py b/api/oss/src/core/applications/dtos.py index 1b186c8ce1..9bdd271c67 100644 --- a/api/oss/src/core/applications/dtos.py +++ b/api/oss/src/core/applications/dtos.py @@ -1,7 +1,7 @@ from typing import Optional, List from uuid import UUID -from pydantic import Field +from pydantic import ConfigDict, Field from oss.src.core.shared.dtos import sync_alias, AliasConfig from oss.src.core.shared.dtos import ( @@ -212,7 +212,7 @@ class ApplicationVariantQuery(WorkflowVariantQuery): class ApplicationRevisionData(WorkflowRevisionData): - pass + model_config = ConfigDict(extra="forbid") class ApplicationRevision( diff --git a/api/oss/src/core/evaluators/dtos.py b/api/oss/src/core/evaluators/dtos.py index e68f4c53ec..348820f5bf 100644 --- a/api/oss/src/core/evaluators/dtos.py +++ b/api/oss/src/core/evaluators/dtos.py @@ -1,7 +1,7 @@ from typing import Optional, List from uuid import UUID -from pydantic import Field +from pydantic import ConfigDict, Field from oss.src.core.shared.dtos import sync_alias, AliasConfig from oss.src.core.shared.dtos import ( @@ -208,7 +208,7 @@ class EvaluatorVariantQuery(WorkflowVariantQuery): class EvaluatorRevisionData(WorkflowRevisionData): - pass + model_config = ConfigDict(extra="forbid") class EvaluatorRevision( diff --git a/api/oss/src/core/workflows/dtos.py b/api/oss/src/core/workflows/dtos.py index b7482185a6..c382789809 100644 --- a/api/oss/src/core/workflows/dtos.py +++ b/api/oss/src/core/workflows/dtos.py @@ -3,7 +3,6 @@ from pydantic import ( BaseModel, - ConfigDict, Field, ) diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index 086c4ece93..ebe60f3e5d 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -564,7 +564,7 @@ class EvaluatorVariantIdAlias(AliasConfig): class EvaluatorRevisionData(WorkflowRevisionData): - pass + model_config = ConfigDict(extra="forbid") class EvaluatorFlags(WorkflowFlags): @@ -714,7 +714,7 @@ class ApplicationVariantEdit(WorkflowVariantEdit): class ApplicationRevisionData(WorkflowRevisionData): - pass + model_config = ConfigDict(extra="forbid") class ApplicationRevision(