diff --git a/src/agentrust_trace/provenance.py b/src/agentrust_trace/provenance.py index a548deb..cff6f2a 100644 --- a/src/agentrust_trace/provenance.py +++ b/src/agentrust_trace/provenance.py @@ -109,6 +109,14 @@ def tool_catalog_hash(tools: list[dict[str, Any]]) -> str: for index, t in enumerate(tools): if not isinstance(t, dict): raise ProvenanceError(f"tools[{index}] must be an object, got {type(t).__name__}") + if ( + "input_schema" in t + and "inputSchema" in t + and anchor_bytes(t["input_schema"]) != anchor_bytes(t["inputSchema"]) + ): + raise ProvenanceError( + f"tools[{index}] carries conflicting input_schema and inputSchema values" + ) normalized = sorted( ( { diff --git a/tests/test_alias_canonical_comparison.py b/tests/test_alias_canonical_comparison.py new file mode 100644 index 0000000..3c0d6e0 --- /dev/null +++ b/tests/test_alias_canonical_comparison.py @@ -0,0 +1,22 @@ +"""Alias equality follows the bytes hashed, including JSON type distinctions.""" +import pytest + +from agentrust_trace.provenance import ProvenanceError, check_tool_catalog, tool_catalog_hash + + +@pytest.mark.parametrize("left,right", [ + ({"const": True}, {"const": 1}), + ({"const": False}, {"const": 0}), + ({"properties": {"x": {"enum": [1]}}}, {"properties": {"x": {"enum": [True]}}}), +]) +def test_python_equal_but_distinct_json_aliases_are_refused(left, right): + signed = {"name": "read", "description": "Read", "input_schema": left} + record = {"tool_catalog": {"hash": tool_catalog_hash([signed]), "tool_count": 1}} + with pytest.raises(ProvenanceError, match="conflicting input_schema and inputSchema"): + check_tool_catalog(record, [{**signed, "inputSchema": right}]) + + +def test_alias_object_key_order_does_not_change_the_hash(): + tool = {"name": "read", "input_schema": {"type": "object", "additionalProperties": False}} + dual = {**tool, "inputSchema": {"additionalProperties": False, "type": "object"}} + assert tool_catalog_hash([dual]) == tool_catalog_hash([tool]) diff --git a/tests/test_provenance_dual_input_schema.py b/tests/test_provenance_dual_input_schema.py new file mode 100644 index 0000000..3edb8f6 --- /dev/null +++ b/tests/test_provenance_dual_input_schema.py @@ -0,0 +1,56 @@ +"""Regression coverage for conflicting MCP tool schema aliases (#297).""" + +from __future__ import annotations + +import pytest + +from agentrust_trace.provenance import ProvenanceError, check_tool_catalog, tool_catalog_hash + + +def _schema(*, additional_properties: bool) -> dict[str, object]: + return { + "type": "object", + "properties": {"path": {"type": "string"}}, + "additionalProperties": additional_properties, + } + + +def _tool(schema: dict[str, object]) -> dict[str, object]: + return { + "name": "read_file", + "description": "Read a file from the sandboxed workspace", + "input_schema": schema, + } + + +def test_equal_schema_aliases_keep_the_existing_wire_compatibility() -> None: + schema = _schema(additional_properties=False) + snake = _tool(schema) + both = {**snake, "inputSchema": schema.copy()} + + assert tool_catalog_hash([both]) == tool_catalog_hash([snake]) + + +def test_conflicting_schema_aliases_are_refused_before_hashing() -> None: + live = _tool(_schema(additional_properties=False)) + live["inputSchema"] = _schema(additional_properties=True) + + with pytest.raises(ProvenanceError, match="conflicting input_schema and inputSchema"): + tool_catalog_hash([live]) + + +def test_check_tool_catalog_cannot_accept_a_decoy_snake_case_schema() -> None: + signed_shape = _tool(_schema(additional_properties=False)) + record = { + "tool_catalog": { + "hash": tool_catalog_hash([signed_shape]), + "tool_count": 1, + } + } + live = { + **signed_shape, + "inputSchema": _schema(additional_properties=True), + } + + with pytest.raises(ProvenanceError, match="conflicting input_schema and inputSchema"): + check_tool_catalog(record, [live])