Skip to content

fix(security): UUID-only ids; filter expressions accepted, camel-ai>=0.2.60, reranker fallback - #2

Merged
bashareid merged 5 commits into
mainfrom
fix/uuid-ids-and-review-2026-09-25
Sep 28, 2026
Merged

bashareid merged 5 commits into
mainfrom
fix/uuid-ids-and-review-2026-09-25

Conversation

@bashareid

Copy link
Copy Markdown
Collaborator

Local commits from the 2026-09-25 review, each with a regression test that fails before and passes after; full offline suite and CI gates green locally. Ids that reach a URL path must now be canonical UUIDs, checked before any request (the SDK sends ../spaces/<id> as a request to a different endpoint).

Commits:

  • fix(security): refuse non-UUID ids before they reach a URL path
  • fix(security): send ids as the checked text; fix the live test the id check broke
  • fix(filters): metadata_filter accepts a filters expression on toolkit and retriever
  • fix(retrieval): decide score kind from the response, not from reranker_id
  • fix(packaging): declare the floors that work; CI installs and tests them

🤖 Generated with Claude Code

bashareid and others added 5 commits September 25, 2026 17:48
The goodmem SDK (0.1.35) interpolates ids into request paths raw and httpx
resolves dot segments before sending, so an id could redirect a request to
another resource. Every GoodMem id is a UUID; anything else is now refused
with GoodMemIdError (a ValueError, like GoodMemUploadError), naming the
argument, before any request is made.

One validator, camel_goodmem/_ids.py require_uuid(value, field), is called at
the top of every id-taking method: get_memory (+content), goodmem_get_space,
update_space, delete_space, list_memories, delete_memory, and create_space's
embedder_id (a body id, checked for consistency). Configured space_ids and
reranker_id are checked at construction and again at every use, since both
are public attributes. Tool signatures declare the ids with the same UUID
pattern, so the CAMEL and MCP tool schemas tell the model; FastMCP also
enforces it. Upper-case UUIDs are accepted and sent lower-case.

Measured against a local server recording every request line, real SDK and
httpx, payloads ../spaces/<U>, a/../../spaces/<U>, %2e%2e/spaces/<U>,
..%2Fspaces%2F<U>, <U>/../../spaces/<U>, "", " <U>", <U>?x=1, <U>#frag,
<U>\n:

Before (0.2.0):
- delete_memory("../spaces/<U>") sent DELETE /v1/spaces/<U> and returned
  {"success": True, "memoryId": "../spaces/<U>"}; the same through the
  CAMEL FunctionTool and the MCP tool.
- delete_space("<U>/../../spaces/<U>") -> DELETE /v1/spaces/<U>, success.
- get_memory / goodmem_get_space / update_space / list_memories sent
  GET|PUT /v1/spaces/<U>[/memories] for the traversals; %2e%2e and ..%2F
  were sent verbatim; list_memories("<U>#frag") hit GET /v1/spaces/<U>;
  list_memories("") silently listed the configured space.
- Malformed space_ids / reranker_id were sent as-is, and list_memories()
  put the configured space id in a URL path.
- New suite on the base code: 336 failed, 9 passed (the 9 are the
  valid-UUID controls).

After: every payload at every entry point raises GoodMemIdError and the
server records zero requests; a valid UUID still reaches exactly
DELETE /v1/memories/<U> etc.

Tests: tests/test_goodmem_ids.py (345, added to CI's offline step); fake
ids in tests/test_goodmem_toolkit.py ("space-1", "m-1", "emb-voyage",
"rr-1", ...) replaced with UUIDs. Offline 414 passed on 3.10-3.13; live 29
skipped without credentials; ruff, ruff format, mypy clean. Version 0.2.1;
pydantic>=2 declared (already required by camel-ai and goodmem); mcp<2 pin
kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check broke

Follow-up to 9bb49fd, from an adversarial review. No external id reached a
URL path unchecked; these are the three problems the review found.

1. A live test was left sending a non-UUID id. TestLiveSpaces::
   test_a_rejected_create_carries_the_servers_message called
   create_space(..., "not-a-uuid") and expected the server's 400. The
   id check now refuses it with GoodMemIdError (a ValueError, not a
   GoodMemError) before any request, so the test failed as soon as the live
   suite ran with credentials. CI never runs it without them.
   Before: run offline against the recorder, it raised GoodMemIdError and
   sent nothing.
   After: it sends a well-formed UUID that names no embedder
   (00000000-0000-7000-8000-000000000000), expects any 4xx carrying the
   server's own body and naming the embedder, and cleans up if a space was
   created. Against the recorder: GET /v1/spaces, POST /v1/spaces -> 404,
   passes. A new live test, test_a_malformed_embedder_id_is_refused_before_
   it_is_sent, asserts that "not-a-uuid" raises GoodMemIdError and that no
   space is created.

2. reranker_id="" meant "no reranker" in 0.2.0 and is now refused at
   construction, which was not called out. It is still refused ("" is one of
   the payloads the check must refuse), but the error now ends "To search
   without a reranker, pass reranker_id=None or leave it out ...". The README's
   ids section and its 0.2.1 table name the change and the
   os.getenv(...) or None idiom, and the constructor docstring says so.

3. Hardening: require_uuid returned value.lower() for a str and str(value) for
   a uuid.UUID. A subclass could pass the check and still change what was
   sent. It now copies a str into a plain str with str.__str__, checks that
   exact text and returns it lower-cased. A uuid.UUID is judged by the text
   its str() produces.
   Before, with _SwappingStr(<real UUID>), a str whose lower/__str__/__format__
   return "../spaces/<V>": delete_space sent DELETE /v1/spaces/<V> and
   returned {"success": True, "spaceId": "../spaces/<V>"}. delete_memory
   sent DELETE /v1/spaces/<V>. get_memory, goodmem_get_space and
   update_space sent GET or PUT /v1/spaces/<V>. list_memories(v), and
   list_memories() with space_ids set later, sent
   GET /v1/spaces/<V>/memories. A uuid.UUID subclass whose __str__ returns
   the traversal did the same at all seven entry points.
   After: the str subclass reaches exactly DELETE /v1/memories/<real>, etc.,
   and the UUID subclass is refused with GoodMemIdError with nothing sent.

Tests (tests/test_goodmem_ids.py, 345 -> 380):
- 7 swapping-str and 7 swapping-UUID cases through the real SDK and httpx
- plain-str return checks
- empty reranker_id hint, at construction and when set later
- a README guard
- the two live TestLiveSpaces tests run against the recorder, which now
  answers an unknown embedder with 404
- an AST check that no live test passes a literal id the validator refuses,
  outside pytest.raises(GoodMemIdError), plus 7 self-tests for that check
Run against 9bb49fd: 24 failed, 356 passed. The 11 new tests that pass there
are controls: the check's self-tests, the plain-str inputs and the
None-reranker body.

Offline 449 passed on Python 3.10, 3.11, 3.12 and 3.13. Live: 30 skipped
without credentials and the CI gate holds. ruff check, ruff format --check
and mypy are clean. The key and TLS gates are clean. python -m build and
twine check pass, and the wheel imports in a clean venv.

Version stays 0.2.1: 9bb49fd already bumped it, and 0.2.1 is neither tagged
nor on PyPI (released: 0.1.0 and 0.2.0). This commit completes that release.
The mcp<2 pin is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and retriever

The README's "Metadata filters" section built an expression with
camel_goodmem.filters, but GoodMemToolkit took metadata_filter only as a dict
(dict(metadata_filter) then from_mapping), and GoodMemRetriever took no filter
at all. compare / one_of / not_equals / any_of could not be applied through
this package.

Measured against a local server recording every request, real SDK + httpx:

Before:
- GoodMemToolkit(..., metadata_filter=filters.all_of(equals, compare,
  one_of)) raised ValueError "dictionary update sequence element #0 has
  length 1; 2 is required" at construction.
- GoodMemRetriever(tk, metadata_filter=...) raised TypeError (unexpected
  keyword argument).
- README blocks: 3 of 6 failed as written (the filters block and the
  retriever block used space_ids=["..."], refused by the UUID check; the
  uploads block had no import -> NameError).
- New TestFilterExpressions: 9 failed, 2 passed (the two dict/empty controls).

After:
- metadata_filter is dict | str on both. A dict is the AND of equalities it
  always was; a str is a filters-built expression sent verbatim; "" means
  no filter; any other type, or a dict value with no safe form (None, list,
  dict), raises GoodMemFilterError at construction instead of on the first
  search. The retriever's filter is ANDed with the toolkit's, so it can
  narrow the toolkit's scope, never widen it. The model still sees only
  query/top_k.
- The recorded request carries
  (CAST(val('$.tenant') AS TEXT) = 'acme') AND (... >= 2026) AND
  (... IN ('note', 'doc')) as spaceKeys[].filter.
- README filters section rewritten with a runnable toolkit + retriever
  example; all 6 README python blocks run against the local server.
- TestFilterExpressions 11 passed; offline suite 460 passed; ruff, ruff
  format, mypy clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r_id

outcome_from_events labelled every hit by configuration
(reranked=bool(reranker_id)). When the configured reranker fails, GoodMem
reports RERANKING_FAILED (and NOT_FOUND naming the reranker) and still
returns the vector-stage hits, scored as negative distances.

Measured with the captured live stream retrieve_degraded_hits.ndjson
(NOT_FOUND + RERANKING_FAILED + one hit, raw -0.5845972) served by a local
server to the real SDK, reranker_id set:

Before:
- goodmem_search: scoreKind "reranker", score -0.5846 (un-negated).
- min_score=0.0: 0 of 1 hits returned, UserWarning "min_score=0.0 removed
  all 1 reranked result(s)" -- Q4a violated, a server-returned hit
  discarded.
- GoodMemRetriever.query(similarity_threshold=0.0): the hit was dropped and
  only the "No results were returned" row came back.
- New tests: 6 failed (one of them the "unrelated status keeps reranker
  scores" control, which failed only because outcome.reranked did not exist).

After:
- RetrievalOutcome.reranked is decided once the stream is complete (a
  RERANKING_FAILED may follow the hits): requested AND no RERANKING_FAILED
  AND no NOT_FOUND naming the reranker. Hits are scoreKind "vector",
  score 0.5846; min_score is applied only when outcome.reranked, so the hit
  is kept with no warning; partial stays true with both statuses.
- An unrelated status (e.g. an UNKNOWN code) leaves reranker scores as-is.
- README Scores section documents the fallback; 0.2.1 changes table row.
- Offline 466 passed; ruff, ruff format, mypy clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The declared floors camel-ai>=0.2.0 and pydantic>=2 were not true.

Measured on Python 3.10, each camel-ai pinned, goodmem 0.1.35, mcp<2:

Before:
- camel-ai 0.2.0, 0.2.10: import camel_goodmem -> ModuleNotFoundError
  camel.logger.
- camel-ai 0.2.20, 0.2.59: ModuleNotFoundError PIL (camel-ai's own
  undeclared import).
- camel-ai 0.2.60, 0.2.70, 0.2.72-0.2.78: import works, but camel-ai's
  BaseToolkit with_timeout wrapper runs each method in a thread and drops
  its exception, so the caller gets IndexError: list index out of range.
  delete_memory("../spaces/<id>") raised IndexError, not GoodMemIdError;
  offline suite 245 failed, 221 passed. 0.2.79 re-raises (diffed
  camel/utils/commons.py 0.2.78 -> 0.2.79); 0.2.79, 0.2.80, 0.2.85, 0.2.90:
  all pass.
- pydantic: with camel-ai 0.2.79 the resolver's lowest is 2.10.6, where on
  Python 3.10 camel-ai's FunctionTool raises "TypeError: issubclass() arg 1
  must be a class" for every method annotated -> dict[str, Any], so
  get_tools() fails: 375 failed, 74 passed. 2.11.0: 449 passed.
- The floors job below, run locally (its run: blocks extracted and executed
  on Python 3.10) against the old pyproject: installs camel-ai 0.2.0,
  pydantic 2.0, mcp 0.9.1; fails at "Package imports at the floors".

After:
- camel-ai>=0.2.79, pydantic>=2.11; mcp<2 kept.
- New CI job `floors` (Python 3.10): uv pip install --resolution
  lowest-direct -e ., asserts the installed camel-ai/goodmem/pydantic equal
  the declared floors (catches an unreachable floor: pydantic>=2 resolves
  to 2.10.6 and fails the check) and mcp<2, imports the package, runs the
  offline suite. Locally: camel-ai 0.2.79, goodmem 0.1.35, pydantic 2.11.0,
  mcp 1.3.0; import ok; 466 passed. With camel-ai>=0.2.60 it fails
  (245 failed). Every run: block in ci.yml passes bash -n.
- README: floors stated under Install; the "what CI runs" block includes
  the floor commands (executed as written); toolkit test count 69 -> 86
  (collected); 0.2.1 changes row. Version stays 0.2.1 (the security commit
  bumped it); no CHANGELOG file exists, the README's "Changes in 0.2.1"
  table is the changelog.

Note: camel-ai 0.2.79 pins tiktoken<0.8, which has no Python 3.13 wheel, so
the floor is only installable on 3.10-3.12; on 3.13 the resolver picks a
newer camel-ai, which the main test matrix covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bashareid
bashareid merged commit 6833c31 into main Sep 28, 2026
8 checks passed
@bashareid
bashareid deleted the fix/uuid-ids-and-review-2026-09-25 branch September 28, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant