JSON-safety validation for ToolMetadata.extras (#773)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Tightens validation on tool metadata, which may break existing tools
that relied on non-JSON-serializable `extras` values or keys; changes
are localized and well-covered by tests.
> 
> **Overview**
> Adds **JSON-safety enforcement** for `ToolMetadata.extras`: top-level
keys must be strings at model construction, and `validate_for_tool()`
now recursively rejects non-JSON-native values (including non-finite
floats) with path-rich `ToolDefinitionError` messages.
> 
> Expands tests to cover valid/invalid nested `extras` cases and
error-message quality, and bumps `arcade-core` version to `4.5.0`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2bab0db3c17f0ddb97868764d10494da543b39e5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Eric Gustin 2026-02-25 09:48:04 -08:00 committed by GitHub
parent 5228c75dc9
commit 25267ab6ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 196 additions and 7 deletions

View file

@ -13,10 +13,11 @@ Defines the metadata model for Arcade tools. This module provides three layers:
- Extras: Arbitrary key/values for custom logic (IDP routing, feature flags, etc.)
"""
import math
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, ValidatorFunctionWrapHandler, field_validator
from arcade_core.errors import ToolDefinitionError
@ -273,7 +274,28 @@ class ToolMetadata(BaseModel):
"""What effects the tool has."""
extras: dict[str, Any] | None = None
"""Arbitrary key/values for custom logic."""
"""Arbitrary key/values for custom logic. Must contain only JSON-native types
(str, int, float, bool, None, dict with string keys, list) at all depths."""
@field_validator("extras", mode="wrap")
@classmethod
def _validate_extras_top_level_keys(
cls, v: dict[str, Any] | None, handler: ValidatorFunctionWrapHandler
) -> dict[str, Any] | None:
"""Intercept Pydantic's type validation to give a clear error for
non-string top-level keys. Full recursive JSON-safety validation
(nested keys + value types) is deferred to validate_for_tool()
which is called when the tool definition is created for the catalog."""
if v is not None and isinstance(v, dict):
bad_keys = {k: type(k).__name__ for k in v if not isinstance(k, str)}
if bad_keys:
examples = ", ".join(f"{k!r} ({t})" for k, t in bad_keys.items())
raise ToolDefinitionError(
f"All keys in ToolMetadata.extras must be strings. "
f"Found non-string key(s): {examples}. "
)
result: dict[str, Any] | None = handler(v)
return result
strict: bool = Field(default=True, exclude=True)
"""Enable validation for logical contradictions. Set False for edge cases.
@ -283,13 +305,26 @@ class ToolMetadata(BaseModel):
def validate_for_tool(self) -> None:
"""
Validate consistency between behavior and classification.
Validate metadata consistency and JSON-safety of extras.
Called by the catalog when creating a tool definition.
Raises:
ToolDefinitionError: If strict=True and validation fails
"""
# JSON-safety check on extras
if self.extras is not None:
errors = _find_json_violations(self.extras, "extras")
if errors:
formatted = "; ".join(errors)
raise ToolDefinitionError(
f"ToolMetadata.extras must contain only JSON-safe "
f"types (str, int, float, bool, None, dict, list). "
f"Found violations: {formatted}. "
f"All dict keys must be strings, and all values must be "
f"JSON-native types."
)
if not self.strict:
return
@ -335,3 +370,36 @@ class ToolMetadata(BaseModel):
"but is marked open_world=False. "
"Fix the contradiction, or set strict=False to bypass."
)
def _find_json_violations(obj: Any, path: str) -> list[str]:
"""Walk a nested structure and return human-readable descriptions of
any non-JSON-native keys or values.
JSON-native: str, int, float, bool, None, dict (string keys only), list.
"""
errors: list[str] = []
if isinstance(obj, dict):
for k, v in obj.items():
key_path = f"{path}[{k!r}]"
if not isinstance(k, str):
errors.append(
f"{key_path} has a non-string key of type {type(k).__name__}"
f"all dict keys must be strings"
)
errors.extend(_find_json_violations(v, key_path))
elif isinstance(obj, list):
for i, item in enumerate(obj):
errors.extend(_find_json_violations(item, f"{path}[{i}]"))
# non-finite floats
elif isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
errors.append(
f"{path} has a non-JSON-safe float value {obj!r}"
f"NaN and Infinity are not valid JSON numbers"
)
# json primitive types
elif not isinstance(obj, (str, int, float, bool, type(None))):
errors.append(
f"{path} has a non-JSON-safe value of type {type(obj).__name__} (got {obj!r})"
)
return errors

View file

@ -1,6 +1,6 @@
[project]
name = "arcade-core"
version = "4.4.2"
version = "4.5.0"
description = "Arcade Core - Core library for Arcade platform"
readme = "README.md"
license = { text = "MIT" }

View file

@ -1,3 +1,5 @@
import datetime
import pytest
from arcade_core.catalog import ToolCatalog
from arcade_core.errors import ToolDefinitionError
@ -26,7 +28,9 @@ class TestEnumCoverage:
def test_all_operations_are_categorized(self):
"""Every Operation must be in _READ_ONLY_OPERATIONS, _MUTATING_OPERATIONS, or _INDETERMINATE_OPERATIONS."""
all_operations = set(Operation)
categorized_operations = _READ_ONLY_OPERATIONS | _MUTATING_OPERATIONS | _INDETERMINATE_OPERATIONS
categorized_operations = (
_READ_ONLY_OPERATIONS | _MUTATING_OPERATIONS | _INDETERMINATE_OPERATIONS
)
# Check that every operation is categorized
uncategorized = all_operations - categorized_operations
@ -160,8 +164,8 @@ class TestToolMetadataValidation:
)
assert len(metadata.classification.service_domains) == 2
def test_extras_accepts_arbitrary_dict(self):
"""Extras field accepts arbitrary key/value pairs."""
def test_extras_accepts_json_native_values(self):
"""Extras field accepts JSON-native key/value pairs."""
metadata = ToolMetadata(
extras={"idp": "entraID", "requires_mfa": True, "max_requests": 100},
)
@ -170,6 +174,123 @@ class TestToolMetadataValidation:
assert metadata.extras["max_requests"] == 100
class TestExtrasJsonSafety:
"""Test that ToolMetadata.extras enforces JSON-native types at all depths.
JSON-native types: str, int, float, bool, None, dict (str keys), list.
Top-level non-string keys are caught at construction time (field_validator).
Nested keys and non-JSON-native values are caught at registration time
(validate_for_tool) where the tool name is available for error context.
"""
@pytest.mark.parametrize(
"extras",
[
pytest.param(None, id="none"),
pytest.param({}, id="empty_dict"),
pytest.param(
{"string": "hello", "int": 42, "float": 3.14, "bool": True, "null": None},
id="flat_json_native_values",
),
pytest.param({"config": {"api_key": "abc", "retries": 3}}, id="nested_dict"),
pytest.param({"tags": ["a", "b"], "counts": [1, 2, 3]}, id="lists"),
pytest.param(
{"l1": {"l2": [{"l3": [1, "two", None, True, 3.0]}]}},
id="deeply_nested",
),
pytest.param(
{"empty_dict": {}, "empty_list": [], "nested": {"also_empty": []}},
id="empty_nested_structures",
),
],
)
def test_valid_json_safe_extras(self, extras: dict | None):
metadata = ToolMetadata(extras=extras)
assert metadata.extras == extras
metadata.validate_for_tool()
# --- Top-level non-string keys: caught at construction time ---
@pytest.mark.parametrize(
"extras",
[
pytest.param({3: "three"}, id="int_key"),
pytest.param({True: "yes"}, id="bool_key"),
pytest.param({None: "null key"}, id="none_key"),
],
)
def test_non_string_top_level_key_rejected_at_construction(self, extras: dict):
with pytest.raises(ToolDefinitionError, match="must be strings"):
ToolMetadata(extras=extras)
# --- Nested non-string keys + non-JSON values: caught by validate_for_tool ---
@pytest.mark.parametrize(
"extras, match",
[
# Non-string keys nested in dicts/lists
pytest.param({"o": {42: "v"}}, "must be strings", id="int_key_nested"),
pytest.param({"o": {True: "v"}}, "must be strings", id="bool_key_nested"),
pytest.param({"o": {(1, 2): "v"}}, "must be strings", id="tuple_key_nested"),
pytest.param({"a": {"b": {42: "v"}}}, "must be strings", id="int_key_deep"),
pytest.param({"items": [{True: "v"}]}, "must be strings", id="bool_key_in_list"),
# Non-JSON-native values at top level
pytest.param({"v": datetime.datetime(2023, 1, 1)}, "JSON-safe", id="datetime_value"),
pytest.param({"v": datetime.date(2023, 1, 1)}, "JSON-safe", id="date_value"),
pytest.param({"v": datetime.timedelta(seconds=60)}, "JSON-safe", id="timedelta_value"),
pytest.param({"v": {1, 2, 3}}, "JSON-safe", id="set_value"),
pytest.param({"v": frozenset([1, 2])}, "JSON-safe", id="frozenset_value"),
pytest.param({"v": (1, 2)}, "JSON-safe", id="tuple_value"),
pytest.param({"v": b"hello"}, "JSON-safe", id="bytes_value"),
# Non-finite floats (not valid JSON per RFC 8259)
pytest.param({"v": float("nan")}, "JSON-safe", id="float_nan"),
pytest.param({"v": float("inf")}, "JSON-safe", id="float_inf"),
pytest.param({"v": float("-inf")}, "JSON-safe", id="float_neg_inf"),
# Non-JSON-native values nested
pytest.param(
{"o": {"i": datetime.datetime(2023, 1, 1)}},
"JSON-safe",
id="datetime_nested_in_dict",
),
pytest.param({"items": [1, "ok", {3, 4}]}, "JSON-safe", id="set_nested_in_list"),
pytest.param(
{"a": [{"b": [datetime.date(2023, 1, 1)]}]},
"JSON-safe",
id="date_deeply_nested",
),
],
)
def test_rejects_non_json_safe_extras_at_validation(self, extras: dict, match: str):
metadata = ToolMetadata(extras=extras)
with pytest.raises(ToolDefinitionError, match=match):
metadata.validate_for_tool()
# --- Error message quality ---
def test_error_includes_path_for_nested_violations(self):
metadata = ToolMetadata(extras={"outer": {42: "bad"}})
with pytest.raises(ToolDefinitionError, match=r"extras\['outer'\]"):
metadata.validate_for_tool()
metadata = ToolMetadata(extras={"outer": datetime.datetime(2023, 1, 1)})
with pytest.raises(ToolDefinitionError, match=r"extras\['outer'\]"):
metadata.validate_for_tool()
def test_error_includes_type_name(self):
metadata = ToolMetadata(extras={"ts": datetime.datetime(2023, 1, 1)})
with pytest.raises(ToolDefinitionError, match="datetime"):
metadata.validate_for_tool()
def test_error_reports_all_violations(self):
metadata = ToolMetadata(extras={"ok_key": {True: "bool key"}, "bad": (1, 2)})
with pytest.raises(ToolDefinitionError) as exc_info:
metadata.validate_for_tool()
msg = str(exc_info.value)
assert "True" in msg
assert "tuple" in msg
class TestToolDecoratorWithMetadata:
"""Test @tool decorator with metadata parameter."""