test(recall): pin the omitted-filter identity binding - #1276
Conversation
#1268 fixed /recall's identity resolution and shipped five tests, every one of which names an identity explicitly — so the DEFAULT path went unpinned. That is the case needing no crafted request: no filter_agent_id, no caller_agent_id, just an ordinary recall. It is also the worse of the two holes in the original report. Pre-fix, caller_agent_id=body.filter_agent_id meant an omitted filter passed None — the tenant-wide visibility a tenant credential gets — and the trust<2 fleet forcing sat inside `if body.filter_agent_id:` so it did not run either. A trust-1 agent issuing the most ordinary possible recall read across fleets. Confirmed by reverting the callsite: caller_agent_id=None where the authenticated agent was expected. Asserted on the ARGUMENTS reaching search_memories rather than on the result set. A results-parity assertion cannot separate "bound to the caller" from "happened to return the same rows" — an unbound read coincides with a bound one whenever the caller has nothing hidden from it, and then the assertion passes while the identity is still wrong. The patch target is load-bearing and is the reason this needed care: /recall re-imports search_memories INSIDE the handler (`from core_api.services.memory_service import search_memories`), so it resolves the name at call time from the service module and never sees a patch applied to the route module's own binding — which is the one /search uses. Patching the route seam silently no-ops, the real search runs, and the assertions then read whatever that call left behind. My first attempt did exactly that and passed for the wrong reason until the probe caught it. Credit: the case and the argument-level assertion are from @zznate's PR #995, the fix for their report in #994. #1268 landed the shared resolver first and closed a vector #995 did not (a spoofed caller_agent_id, which /recall accepts because it parses SearchRequest), but shipped without this test. It is theirs, and their choice of patch seam was right where mine was wrong. Test-only, so claude-review's source-file filter will skip it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
|
Claude Code Review — skipped: no source files to review (only tests/docs/config) |
|
@erni-a ready for human review at head Worth flagging: this PR has had no automated review. What it adds: the omitted-filter case for The behaviour on Confirmed by probe — reverting only the Two details a reviewer should not have to rediscover, both called out in comments on the test:
Attribution: the case and the argument-level assertion are @zznate's, from #995 (their fix for their report in #994). #995 is closed as superseded — #1268 covered a vector it did not — but this test was not superseded, and it is theirs. Full root suite 6049 passed / 0 failed; ruff check and format at CI's |
Adds the one test #1268 should have had, from @zznate's #995.
The gap
#1268 fixed
/recall's identity resolution and shipped five tests. Every one of them names an identity explicitly — a spoofedfilter_agent_id, a spoofedcaller_agent_id, an agent naming itself, a tenant key naming a peer,caller_agent_idhonoured. So the default path went unpinned: no filter, no caller id, just an ordinary recall.That is also the worse of the two consequences in the original report (#994), because it needs no crafted request. Pre-fix,
caller_agent_id=body.filter_agent_idmeant an omitted filter passedNone— the tenant-wide visibility a tenant credential gets — and the trust<2 fleet forcing sat insideif body.filter_agent_id:, so it did not run either. A trust-1 agent issuing the most ordinary possible recall read across fleets.The behaviour is correct on
maintoday. It just was not held there by anything.Confirmed, not assumed
Reverting only the
/recallcallsite to the pre-fix derivation:Noneis precisely the tenant-wide identity the finding described.Asserted on arguments, not on results
The test captures what reaches
search_memoriesrather than comparing result sets. A results-parity assertion cannot separate "bound to the caller" from "happened to return the same rows" — an unbound read coincides with a bound one whenever the caller has nothing hidden from it, and then the assertion passes while the identity is still wrong.The patch seam is the interesting part
/recallre-imports the function inside the handler:So it resolves the name at call time from the service module and never sees a patch applied to the route module's own binding — which is the one
/searchuses. Patching the route seam silently no-ops: the real search runs and the assertions then read whatever that call left behind.My first attempt did exactly that and passed for the wrong reason until the removal probe exposed it. #995 patched the
memory_serviceseam from the start. Their choice was right where mine was wrong, and there is now a comment on the test saying why, so the next person does not re-learn it.Attribution
The case and the argument-level assertion are @zznate's, from #995 — their fix for their own report in #994. #1268 landed the shared resolver first and closed a vector #995 did not (a spoofed
caller_agent_id, which/recallaccepts because it parsesSearchRequest), which is why #995 is superseded rather than merged. This test is not.Verification
.venv/bin/python -m pytest. The diff is one new test function.ruff checkandruff format --checkat CI'stests/scope — clean.legacy_name_ratchet.py→ No new lines. ·do_not_touch_sentinel.py→ All 39 protected strings survive. Both aftergit add.origin/mainat7c94e1db.claude-review's source-file filter will skip this PR by design.🤖 Generated with Claude Code