Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions agentex/src/api/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,38 @@ def _parse_finite_float(raw: str) -> float:
return value


def _reject_nul_bytes(value: object) -> None:
r"""Reject ``\u0000`` anywhere in the decoded filter.

JSON permits the escape and ``json.loads`` decodes it happily, but a
PostgreSQL ``text`` value cannot hold a NUL. The driver only discovers that
while encoding the JSONB bind parameter, which raises
``asyncpg.exceptions.UntranslatableCharacterError`` from inside the query --
too late for any handler but the catch-all, so caller error became a 500.
"""
if isinstance(value, str):
if "\x00" in value:
raise ValueError("strings must not contain \\u0000")
elif isinstance(value, dict):
for key, item in value.items():
_reject_nul_bytes(key)
_reject_nul_bytes(item)
elif isinstance(value, list):
for item in value:
_reject_nul_bytes(item)


def _parse_agent_card_metadata(raw: str) -> dict:
"""Decode the JSON-encoded ``agent_card_metadata`` query value into a dict.
r"""Decode the JSON-encoded ``agent_card_metadata`` query value into a dict.

Python's ``json`` module accepts values that aren't interoperable JSON --
the bare ``NaN``/``Infinity`` constants, float literals that overflow to
infinity, and integers too large to render. Those all satisfy an
``isinstance(..., dict)`` check but blow up further down at the JSONB bind
parameter, turning caller error into an uncontrolled 500. Reject them here
so every malformed input surfaces as a 400.
parameter, turning caller error into an uncontrolled 500. A ``\u0000``
escape decodes to a perfectly ordinary ``str`` and fails the same way, one
layer lower, in the driver. Reject them all here so every malformed input
surfaces as a 400.
"""
try:
parsed = json.loads(
Expand All @@ -161,6 +184,13 @@ def _parse_agent_card_metadata(raw: str) -> dict:
status_code=400,
detail="agent_card_metadata must be a JSON object",
)
try:
_reject_nul_bytes(parsed)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f"agent_card_metadata is not a storable JSON object: {exc}",
) from exc
return parsed


Expand Down
22 changes: 22 additions & 0 deletions agentex/tests/integration/api/agents/test_agents_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,28 @@ async def test_list_agents_agent_card_metadata_non_finite_numbers_return_400(
assert response.status_code == 400
assert "agent_card_metadata" in response.json()["message"]

@pytest.mark.parametrize(
"raw_filter",
[
'{"skill": "x\\u0000"}',
'{"skill\\u0000": "x"}',
'{"skill": ["x\\u0000"]}',
'{"skill": {"nested": "x\\u0000"}}',
],
)
@pytest.mark.asyncio
async def test_list_agents_agent_card_metadata_nul_byte_returns_400(
self, isolated_client, raw_filter
):
r"""A `\u0000` escape decodes to a valid str but cannot be stored in a
PostgreSQL text value. Rejected here, not by the driver mid-query where
it would surface as a 500."""
response = await isolated_client.get(
"/agents", params={"agent_card_metadata": raw_filter}
)
assert response.status_code == 400
assert "agent_card_metadata" in response.json()["message"]

@pytest.mark.asyncio
async def test_list_agents_agent_card_metadata_empty_object_requires_metadata(
self, isolated_client
Expand Down
Loading