fix: harden composite input schema validation

This commit is contained in:
lda
2026-08-13 17:38:39 +07:00 Verified
parent fa22a1a6f7
commit 4120174a09
5 changed files with 631 additions and 82 deletions
+80 -14
View File
@@ -65,9 +65,9 @@ from .operation_context import WorkflowOperationContext
from .schema_projection import (
project_output_property_to_state_schema,
project_schema_path_to_schema_path,
schema_fragment_at_path,
schema_path_exists,
validate_json_value_at_schema_path,
schema_fragment_at_location,
schema_location_is_explicit,
validate_json_value_at_schema_location,
)
_PROJECT_DRAFT_WORKSPACE = JsonProjector(DraftWorkspaceResult)
@@ -618,11 +618,20 @@ class WorkflowDraftAuthoringApi:
for index, binding in enumerate(bindings):
target_parts = binding.target.parts
try:
schema_fragment_at_path(
schema_fragment_at_location(
capability_schema,
target_parts,
label="capability input schema",
)
if not schema_location_is_explicit(
capability_schema,
target_parts,
label="capability input schema",
):
raise ValueError(
f"capability input schema path {'.'.join(target_parts)!r} "
"is not declared"
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} "
@@ -647,9 +656,9 @@ class WorkflowDraftAuthoringApi:
raise ValueError(
f"bindings[{index}].value for target '.' must be a JSON object"
)
validate_json_value_at_schema_path(
validate_json_value_at_schema_location(
schema=capability_schema,
parts=target_parts,
location=target_parts,
value=binding.value,
label=f"bindings[{index}].value",
)
@@ -662,13 +671,18 @@ class WorkflowDraftAuthoringApi:
target_schema = (
projected_input if source.root == "input" else projected_state
)
if not schema_path_exists(target_schema, source.parts):
if not schema_location_is_explicit(
target_schema,
source.parts,
label=f"{source.root} source schema",
):
target_schema = project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=capability_schema,
source_parts=target_parts,
target_parts=source.parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
if source.root == "input":
projected_input = target_schema
@@ -802,11 +816,20 @@ class WorkflowDraftAuthoringApi:
continue
try:
source_schemas[index] = source_schema
source_fragments[index] = schema_fragment_at_path(
source_fragments[index] = schema_fragment_at_location(
source_schema,
binding.path.parts,
label=f"workflow {binding.path.root} schema",
)
if not schema_location_is_explicit(
source_schema,
binding.path.parts,
label=f"workflow {binding.path.root} schema",
):
raise ValueError(
f"workflow {binding.path.root} schema path "
f"{'.'.join(binding.path.parts)!r} is not declared"
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
@@ -827,7 +850,11 @@ class WorkflowDraftAuthoringApi:
)
source_schema = source_schemas.get(index)
if source_schema is None:
if not schema_path_exists(projected, target_parts):
if not schema_location_is_explicit(
projected,
target_parts,
label="workflow output schema",
):
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
"requires a declared output target"
@@ -851,6 +878,7 @@ class WorkflowDraftAuthoringApi:
source_parts=binding.path.parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
except ValueError as exc:
raise ValueError(
@@ -865,13 +893,17 @@ class WorkflowDraftAuthoringApi:
raise ValueError(
f"bindings[{index}].value for root target must be an object"
)
if not schema_path_exists(projected, target_parts):
if not schema_location_is_explicit(
projected,
target_parts,
label="workflow output schema",
):
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} is not declared"
)
validate_json_value_at_schema_path(
validate_json_value_at_schema_location(
schema=projected,
parts=target_parts,
location=target_parts,
value=binding.value,
label=f"bindings[{index}].value",
schema_label="workflow output schema",
@@ -928,11 +960,20 @@ class WorkflowDraftAuthoringApi:
for index, binding in enumerate(bindings):
try:
schema_fragment_at_path(
schema_fragment_at_location(
capability_schema,
binding.source.parts,
label="capability output schema",
)
if not schema_location_is_explicit(
capability_schema,
binding.source.parts,
label="capability output schema",
):
raise ValueError(
f"capability output schema path "
f"{'.'.join(binding.source.parts)!r} is not declared"
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].source {str(binding.source)!r} "
@@ -954,6 +995,7 @@ class WorkflowDraftAuthoringApi:
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
except ValueError as exc:
raise ValueError(
@@ -1033,7 +1075,11 @@ class WorkflowDraftAuthoringApi:
target_schema = workspace.draft.get(schema_key, {})
if not isinstance(target_schema, dict):
raise ValueError(f"draft {schema_key} must be an object")
if schema_path_exists(target_schema, source_parts):
if schema_location_is_explicit(
target_schema,
source_parts,
label=f"{source_root} source schema",
):
projected = target_schema
else:
projected = project_schema_path_to_schema_path(
@@ -1041,6 +1087,7 @@ class WorkflowDraftAuthoringApi:
source_schema=input_schema,
source_parts=target_parts,
target_parts=source_parts,
allow_additional_properties=True,
)
input_bindings = _upsert_input_path_binding(
step.get("input", []),
@@ -1068,6 +1115,14 @@ class WorkflowDraftAuthoringApi:
output_schema_source = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
if not schema_location_is_explicit(
output_schema_source,
source_parts,
label="output schema",
):
raise ValueError(
f"source schema path {'.'.join(source_parts)!r} is not declared"
)
state_path_str = format_toml_path_segments(("state", *target_parts))
output_target_str = format_toml_path_segments(target_parts)
@@ -1080,6 +1135,7 @@ class WorkflowDraftAuthoringApi:
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
output_schema = workspace.draft.get("output_schema", {})
@@ -1091,6 +1147,7 @@ class WorkflowDraftAuthoringApi:
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
step_output_bindings, previous_state_path = _upsert_step_output_binding(
@@ -1135,6 +1192,14 @@ class WorkflowDraftAuthoringApi:
output_schema = (
spec.output_schema_contract or spec.output_model.model_json_schema()
)
if not schema_location_is_explicit(
output_schema,
source_parts,
label="output schema",
):
raise ValueError(
f"source schema path {'.'.join(source_parts)!r} is not declared"
)
target_schema = workspace.draft.get("state_schema", {})
if not isinstance(target_schema, dict):
raise ValueError("draft state_schema must be an object")
@@ -1144,6 +1209,7 @@ class WorkflowDraftAuthoringApi:
source_parts=source_parts,
target_parts=target_parts,
allow_existing_equivalent=True,
allow_additional_properties=True,
)
output_bindings, _previous_state_path = _upsert_step_output_binding(
step.get("output", []),
+217 -64
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Literal
from jsonschema import Draft202012Validator, ValidationError
@@ -21,7 +21,7 @@ from .schema_projection import (
_resolve_local_reference,
project_schema_path_to_schema_path,
schema_fragment_at_location,
schema_path_exists,
schema_location_is_explicit,
)
Compatibility = Literal["compatible", "incompatible", "unsupported"]
@@ -48,6 +48,10 @@ _STRUCTURAL_KEYWORDS = frozenset(
"prefixItems",
"minItems",
"maxItems",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"const",
"enum",
"$ref",
@@ -56,6 +60,26 @@ _STRUCTURAL_KEYWORDS = frozenset(
}
)
_NUMERIC_CONSTRAINT_KEYWORDS = frozenset(
{"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"}
)
_MAX_SCHEMA_NORMALIZATION_NODES = 2048
@dataclass
class _SchemaNormalizationContext:
"""Bound recursive local-schema expansion independently of call depth."""
nodes: int = 0
active_refs: set[str] = field(default_factory=set)
def visit(self, label: str) -> None:
self.nodes += 1
if self.nodes > _MAX_SCHEMA_NORMALIZATION_NODES:
raise ValueError(
f"schema normalization exceeds the global node budget at {label}"
)
@dataclass(frozen=True)
class ExpressionProjection:
@@ -121,12 +145,19 @@ def validate_and_project_input_expression(
else:
source_document = projected_state
if schema_path_exists(source_document, source_path.parts):
try:
source_fragment = schema_fragment_at_location(
source_document,
source_path.parts,
label=f"{source_path.root} source schema",
)
except ValueError:
source_fragment = None
if source_fragment is not None and schema_location_is_explicit(
source_document,
source_path.parts,
label=f"{source_path.root} source schema",
):
compatibility = _schema_assignability(
source_fragment,
fragment,
@@ -270,6 +301,13 @@ def _schema_assignability(
source_label: str,
target_label: str,
) -> Compatibility:
"""Classify whether every value allowed by ``source`` fits ``target``.
This is deliberately a conservative three-state relation: ``compatible``
proves the subset relation, ``incompatible`` proves a counterexample, and
``unsupported`` means the supported subset of JSON Schema cannot decide.
The caller must reject the last state rather than treating it as success.
"""
try:
source_normalized = _canonical_schema(source, label=source_label)
target_normalized = _canonical_schema(target, label=target_label)
@@ -278,16 +316,22 @@ def _schema_assignability(
if source_normalized == target_normalized:
return "compatible"
source_values = _finite_schema_values(source_normalized)
target_values = _finite_schema_values(target_normalized)
if source_values is not None:
validator = Draft202012Validator(target_normalized)
if all(validator.is_valid(value) for value in source_values):
return "compatible"
return "incompatible"
if target_values is not None:
return "unsupported"
source_types = _schema_types(source_normalized)
target_types = _schema_types(target_normalized)
if source_types is None or target_types is None:
if source_types is None and target_types is not None:
# An unconstrained source is not proven to satisfy a typed target.
# Enum/const sources are handled separately because their finite
# values can still be checked against the target constraint.
if "const" not in source_normalized and "enum" not in source_normalized:
return "unsupported"
return _enum_assignability(source_normalized, target_normalized)
if not target_normalized:
return "compatible"
return "unsupported"
if not _types_assignable(source_types, target_types):
return "incompatible"
@@ -305,9 +349,10 @@ def _schema_assignability(
"const",
"enum",
}
if constraint_keys:
unsupported_constraints = constraint_keys - _NUMERIC_CONSTRAINT_KEYWORDS
if unsupported_constraints:
return "unsupported"
return _enum_assignability(source_normalized, target_normalized)
return _numeric_constraint_assignability(source_normalized, target_normalized)
def _object_assignability(
@@ -342,21 +387,45 @@ def _object_assignability(
return status
elif target.get("additionalProperties") is False:
return "incompatible"
elif isinstance(target.get("additionalProperties"), Mapping):
status = _schema_assignability(
source_child,
target["additionalProperties"],
source_label=f"{source_label}.{name}",
target_label=f"{target_label}.additionalProperties",
)
if status != "compatible":
return status
source_additional = source.get("additionalProperties", True)
target_additional = target.get("additionalProperties", True)
if target_additional is False and source_additional is not False:
return "incompatible"
if isinstance(source_additional, Mapping) and isinstance(
target_additional, Mapping
):
return _schema_assignability(
source_additional,
target_additional,
source_label=f"{source_label}.additionalProperties",
target_label=f"{target_label}.additionalProperties",
)
if isinstance(target_additional, Mapping) and source_additional is True:
return "unsupported"
for name, target_child in target_properties.items():
if name in source_properties:
continue
if source_additional is False:
continue
if isinstance(source_additional, Mapping):
status = _schema_assignability(
source_additional,
target_child,
source_label=f"{source_label}.additionalProperties",
target_label=f"{target_label}.{name}",
)
if status != "compatible":
return status
else:
return "unsupported"
if isinstance(target_additional, Mapping):
if isinstance(source_additional, Mapping):
return _schema_assignability(
source_additional,
target_additional,
source_label=f"{source_label}.additionalProperties",
target_label=f"{target_label}.additionalProperties",
)
if source_additional is True:
return "unsupported"
return "compatible"
@@ -366,6 +435,21 @@ def _array_assignability(
source_label: str,
target_label: str,
) -> Compatibility:
source_min = source.get("minItems")
target_min = target.get("minItems")
if isinstance(target_min, int) and (
(not isinstance(source_min, int) and target_min > 0)
or isinstance(source_min, int)
and source_min < target_min
):
return "incompatible"
source_max = source.get("maxItems")
target_max = target.get("maxItems")
if isinstance(target_max, int) and (
not isinstance(source_max, int) or source_max > target_max
):
return "incompatible"
source_prefix = source.get("prefixItems")
target_prefix = target.get("prefixItems")
if source_prefix is not None or target_prefix is not None:
@@ -389,8 +473,16 @@ def _array_assignability(
if status != "compatible":
return status
source_items = source.get("items")
target_items = target.get("items")
source_items = source.get("items", True)
target_items = target.get("items", True)
if source_items is False:
return "compatible"
if target_items is False:
return "incompatible"
if target_items is True:
return "compatible"
if source_items is True:
return "unsupported"
if isinstance(source_items, Mapping) and isinstance(target_items, Mapping):
return _schema_assignability(
source_items,
@@ -398,48 +490,89 @@ def _array_assignability(
source_label=f"{source_label}.items",
target_label=f"{target_label}.items",
)
if source_items == target_items:
return "compatible"
if target_items is None or source_items is None:
return "unsupported"
return "incompatible"
def _enum_assignability(
source: Mapping[str, Any],
target: Mapping[str, Any],
) -> Compatibility:
if "const" in target:
if "const" in source:
return (
"compatible" if source["const"] == target["const"] else "incompatible"
)
if "enum" in source:
values = source["enum"]
return (
"compatible"
if isinstance(values, list) and values == [target["const"]]
else "incompatible"
)
return "unsupported"
if "enum" in target:
accepted = target["enum"]
if not isinstance(accepted, list):
return "unsupported"
if "const" in source:
return "compatible" if source["const"] in accepted else "incompatible"
if "enum" in source and isinstance(source["enum"], list):
return (
"compatible"
if set(source["enum"]).issubset(accepted)
else "incompatible"
)
return "compatible"
return "unsupported"
def _types_assignable(source: set[str], target: set[str]) -> bool:
normalized_source = {"number" if item == "integer" else item for item in source}
return normalized_source.issubset(target)
return all(
any(
source_type == target_type
or source_type == "integer"
and target_type == "number"
for target_type in target
)
for source_type in source
)
def _finite_schema_values(schema: Mapping[str, Any]) -> list[Any] | None:
"""Return the finite value set represented by a const or enum constraint."""
if "const" in schema:
value = schema["const"]
accepted = schema.get("enum")
if isinstance(accepted, list) and value not in accepted:
return []
return [value]
values = schema.get("enum")
if isinstance(values, list):
return values
return None
def _numeric_constraint_assignability(
source: Mapping[str, Any], target: Mapping[str, Any]
) -> Compatibility:
"""Compare the supported numeric interval constraints conservatively."""
source_lower = _numeric_bound(source, lower=True)
target_lower = _numeric_bound(target, lower=True)
if target_lower is not None and (
source_lower is None or not _lower_bound_contains(source_lower, target_lower)
):
return "incompatible"
source_upper = _numeric_bound(source, lower=False)
target_upper = _numeric_bound(target, lower=False)
if target_upper is not None and (
source_upper is None or not _upper_bound_contains(source_upper, target_upper)
):
return "incompatible"
return "compatible"
def _numeric_bound(
schema: Mapping[str, Any], *, lower: bool
) -> tuple[int | float, bool] | None:
names = (
("minimum", "exclusiveMinimum") if lower else ("maximum", "exclusiveMaximum")
)
candidates = [
(schema[name], name.startswith("exclusive"))
for name in names
if isinstance(schema.get(name), (int, float))
and not isinstance(schema.get(name), bool)
]
if not candidates:
return None
return (
max(candidates, key=lambda item: (item[0], item[1]))
if lower
else min(candidates, key=lambda item: (item[0], not item[1]))
)
def _lower_bound_contains(
source: tuple[int | float, bool], target: tuple[int | float, bool]
) -> bool:
if source[0] != target[0]:
return source[0] > target[0]
return source[1] or not target[1]
def _upper_bound_contains(
source: tuple[int | float, bool], target: tuple[int | float, bool]
) -> bool:
if source[0] != target[0]:
return source[0] < target[0]
return source[1] or not target[1]
def _schema_types(schema: Mapping[str, Any]) -> set[str] | None:
@@ -476,8 +609,25 @@ def _canonical_schema(
*,
label: str,
root_schema: Mapping[str, Any] | None = None,
context: _SchemaNormalizationContext | None = None,
) -> dict[str, Any]:
normalization = context or _SchemaNormalizationContext()
normalization.visit(label)
canonical_root = root_schema if root_schema is not None else schema
reference = schema.get("$ref")
if isinstance(reference, str):
if reference in normalization.active_refs:
raise ValueError(f"cyclic local schema reference {reference!r} at {label}")
normalization.active_refs.add(reference)
try:
return _canonical_schema(
_resolved_schema(schema, label=label, root_schema=canonical_root),
label=label,
root_schema=canonical_root,
context=normalization,
)
finally:
normalization.active_refs.remove(reference)
resolved = _resolved_schema(schema, label=label, root_schema=canonical_root)
if any(keyword in resolved for keyword in _COMPOSITION_KEYWORDS):
raise ValueError(f"unsupported schema composition at {label}")
@@ -496,6 +646,7 @@ def _canonical_schema(
child,
label=f"{label}.{name}",
root_schema=canonical_root,
context=normalization,
)
for name, child in value.items()
if isinstance(name, str) and isinstance(child, Mapping)
@@ -505,6 +656,7 @@ def _canonical_schema(
value,
label=f"{label}.{key}",
root_schema=canonical_root,
context=normalization,
)
elif key == "prefixItems" and isinstance(value, list):
canonical[key] = [
@@ -512,6 +664,7 @@ def _canonical_schema(
child,
label=f"{label}.prefixItems[{index}]",
root_schema=canonical_root,
context=normalization,
)
for index, child in enumerate(value)
if isinstance(child, Mapping)
+121 -4
View File
@@ -131,6 +131,57 @@ def schema_fragment_at_location(
return fragment
def schema_location_is_explicit(
schema: JsonObject,
location: Sequence[SchemaLocationPart],
*,
label: str = "schema",
) -> bool:
"""Return whether a location has a declared or schema-valued path.
JSON Schema treats ``additionalProperties: true`` as an open-ended,
unconstrained object. That is different from a schema-valued
``additionalProperties`` entry, which gives authoring a concrete fragment
to validate against. Callers use this distinction to preserve deferred
projection for genuinely unknown source paths.
"""
_check_schema(label, schema)
current: Mapping[str, Any] = schema
traversed: list[SchemaLocationPart] = []
for part in location:
current = _resolve_local_reference(
schema,
current,
label=_format_schema_location(traversed) or label,
)
if isinstance(part, int):
if current.get("items") is True and not (
isinstance(current.get("prefixItems"), list)
and part < len(current["prefixItems"])
):
return False
child = _array_item_schema(
current,
part,
label=label,
location=(*traversed, part),
)
else:
properties = current.get("properties")
if isinstance(properties, Mapping) and part in properties:
child = properties[part]
else:
additional = current.get("additionalProperties", True)
if not isinstance(additional, Mapping):
return False
child = additional
if not isinstance(child, Mapping):
return False
current = child
traversed.append(part)
return True
def validate_json_value_at_schema_path(
*,
schema: JsonObject,
@@ -154,6 +205,25 @@ def validate_json_value_at_schema_path(
) from exc
def validate_json_value_at_schema_location(
*,
schema: JsonObject,
location: Sequence[SchemaLocationPart],
value: object,
label: str,
schema_label: str = "capability input schema",
) -> None:
"""Validate one JSON-compatible literal at an object/array schema location."""
fragment = schema_fragment_at_location(schema, location, label=schema_label)
path = _format_schema_location(location) or "."
try:
Draft202012Validator(fragment).validate(value)
except ValidationError as exc:
raise ValueError(
f"{label} does not satisfy schema at {path!r}: {exc.message}"
) from exc
def project_schema_path_to_schema_path(
*,
target_schema: JsonObject,
@@ -161,6 +231,7 @@ def project_schema_path_to_schema_path(
source_parts: tuple[str, ...],
target_parts: tuple[str, ...],
allow_existing_equivalent: bool = False,
allow_additional_properties: bool = False,
) -> JsonObject:
"""Copy one nested source subschema into a target object-property path."""
if not target_parts:
@@ -172,10 +243,10 @@ def project_schema_path_to_schema_path(
source_value = (
source_schema
if not source_parts
else _schema_at_path(
source_schema,
source_parts,
label="source schema",
else (
_schema_at_location(source_schema, source_parts, label="source schema")
if allow_additional_properties
else _schema_at_path(source_schema, source_parts, label="source schema")
)
)
@@ -318,6 +389,52 @@ def _check_schema(name: str, schema: JsonObject) -> None:
raise ValueError(f"{name} is not valid JSON Schema: {exc.message}") from exc
def _schema_at_location(
root_schema: Mapping[str, Any],
location: Sequence[SchemaLocationPart],
*,
label: str,
) -> Mapping[str, Any]:
"""Select a raw fragment while allowing schema-valued additional properties."""
current: Mapping[str, Any] = root_schema
traversed: list[SchemaLocationPart] = []
for part in location:
current = _resolve_local_reference(
root_schema,
current,
label=_format_schema_location(traversed) or label,
)
if isinstance(part, int):
child = _array_item_schema(
current,
part,
label=label,
location=(*traversed, part),
)
else:
properties = current.get("properties")
if isinstance(properties, Mapping) and part in properties:
child = properties[part]
else:
additional = current.get("additionalProperties", True)
if not isinstance(additional, Mapping):
raise ValueError(
f"{label} location "
f"{_format_schema_location((*traversed, part))!r} "
"is not declared"
)
child = additional
if not isinstance(child, Mapping):
raise ValueError(
f"{label} location "
f"{_format_schema_location((*traversed, part))!r} "
"is not a JSON Schema object"
)
current = child
traversed.append(part)
return current
def _schema_at_path(
root_schema: Mapping[str, Any],
parts: Sequence[str],