fix(provider): scan rejects non-dict sort with InvalidArguments, never AttributeError

heid-bug-hunt panel (Gróa + Hulda, confirmed-from-code) caught that a truthy
non-dict `sort` (e.g. sort="updated_at" or sort=["updated_at"]) reached
`(sort or {}).get(...)` and crashed with AttributeError instead of the
InvalidArguments PRE-003 promises for malformed caller-controlled input. Add an
isinstance guard before field extraction. Test covers str/list/int sort values.
This commit is contained in:
vh
2026-07-15 08:39:30 -07:00
parent 22e7a1b0e7
commit 25ccb5c75b
4 changed files with 12 additions and 2 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.20.12" version = "0.20.13"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+2
View File
@@ -383,6 +383,8 @@ class RatatoskrMemoryStore:
scope_all = scope_all or {} scope_all = scope_all or {}
scope_any = scope_any or [] scope_any = scope_any or []
_validate_scope(scope_all, scope_any) # PRE-002 (same lattice as search) _validate_scope(scope_all, scope_any) # PRE-002 (same lattice as search)
if sort is not None and not isinstance(sort, dict): # PRE-003: malformed sort => reject, never crash
raise InvalidArguments("sort must be an object with field and direction")
field = (sort or {}).get("field", "updated_at") field = (sort or {}).get("field", "updated_at")
direction = (sort or {}).get("direction", "desc") direction = (sort or {}).get("direction", "desc")
if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"): # PRE-003 if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"): # PRE-003
+8
View File
@@ -427,6 +427,14 @@ async def test_scan_unadvertised_sort_field_rejected():
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "salience", "direction": "desc"}) await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "salience", "direction": "desc"})
async def test_scan_non_dict_sort_rejected():
# PRE-003: a truthy non-dict sort (caller-controlled) -> InvalidArguments, never AttributeError.
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
for bad in ("updated_at", ["updated_at"], 5):
with pytest.raises(InvalidArguments):
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort=bad)
async def test_scan_records_carry_person_prime_filter_fields(): async def test_scan_records_carry_person_prime_filter_fields():
# The client _scan_filter_matches keys on agent_id + subject + worldtree_scope; a record # The client _scan_filter_matches keys on agent_id + subject + worldtree_scope; a record
# missing any is silently dropped -> the scan record must carry them verbatim. # missing any is silently dropped -> the scan record must carry them verbatim.
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.20.12" version = "0.20.13"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },