You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
POST /api/v1/recall shares its request body with POST /api/v1/search, but it never received the identity binding that #801 gave /search (H-14 in the 2026-06-11 audit, linked from #799). Two consequences for an agent credential, that is, a request with MEMCLAW_API_KEY set on the server and X-Agent-ID bound:
The filter is not checked against the caller./search returns 403 when filter_agent_id names another agent. /recall accepts it, runs its trust<2 fleet forcing against the named agent, and uses that agent as the visibility identity.
An omitted filter means tenant-wide visibility./recall passes caller_agent_id=body.filter_agent_id to search_memories, so with no filter it passes None, which is the identity a tenant credential gets. The trust<2 fleet forcing also sits inside if body.filter_agent_id:, so it does not run either. /search binds eff_agent_id = body.filter_agent_id or auth.agent_id and forces the fleet for a trust-1 agent whether or not the filter is present.
So a trust-1 agent in one fleet reads another fleet's scope_team rows through /recall, with content, while /search refuses the same request. The MCP twin, caura_recall, already passes the authenticated agent_id as caller_agent_id and is not affected. Source: core-api/src/core_api/routes/memories.py, recall_endpoint (line 1960 on 71e9981), compared with search (line 1658).
Steps to reproduce
With MEMCLAW_API_KEY set so that X-Agent-ID is bound, and KEY holding that value. R is any unique suffix.
U=http://localhost:8000/api/v1; R=$(date +%s)c() { curl -s -H "X-API-Key: $KEY" -H "Content-Type: application/json" -H "X-Agent-ID: $1""${@:2}"; }
# two fleets, one agent each; both agents stay at the default trust level 1
c owner-$R -X POST $U/fleet -d "{\"tenant_id\":\"default\",\"fleet_id\":\"fleet-a-$R\"}"
c peer-$R -X POST $U/fleet -d "{\"tenant_id\":\"default\",\"fleet_id\":\"fleet-b-$R\"}"# the owner writes a team-scoped row in fleet a
c owner-$R -X POST $U/memories -d "{\"tenant_id\":\"default\",\"agent_id\":\"owner-$R\",\"fleet_id\":\"fleet-a-$R\",\"visibility\":\"scope_team\",\"content\":\"The canary-$R rollout plan moves the billing service to region eu-west-3 on Friday.\"}"# the peer registers in fleet b
c peer-$R -X POST $U/memories -d "{\"tenant_id\":\"default\",\"agent_id\":\"peer-$R\",\"fleet_id\":\"fleet-b-$R\",\"visibility\":\"scope_agent\",\"content\":\"registration note for peer-$R\"}"
sleep 3
# 1. the peer, no filter
c peer-$R -X POST $U/search -d "{\"tenant_id\":\"default\",\"query\":\"canary-$R rollout plan\",\"top_k\":5}"| jq '[.items[].content] | map(contains("canary-'$R'")) | any'
c peer-$R -X POST $U/recall -d "{\"tenant_id\":\"default\",\"query\":\"canary-$R rollout plan\",\"top_k\":5}"| jq '{canary: ([.items[].content] | map(contains("canary-'$R'")) | any), fleets: ([.items[].fleet_id] | unique)}'# 2. the peer names the owner in filter_agent_id
c peer-$R -X POST $U/search -d "{\"tenant_id\":\"default\",\"query\":\"canary-$R rollout plan\",\"top_k\":5,\"filter_agent_id\":\"owner-$R\"}" -o /dev/null -w "%{http_code}\n"
c peer-$R -X POST $U/recall -d "{\"tenant_id\":\"default\",\"query\":\"canary-$R rollout plan\",\"top_k\":5,\"filter_agent_id\":\"owner-$R\"}" -w "%{http_code}\n"| jq -c '[.items[].content] | map(contains("canary-'$R'")) | any'
Expected behavior
/recall behaves as /search does on the same body:
No filter: the trust-1 peer is forced to its own fleet and does not see the fleet-a row. false from both calls.
Filter naming the owner: 403 from both calls, filter_agent_id ... does not match the authenticated agent identity.
A tenant or user credential (no bound agent) keeps full-tenant recall and may still filter by any agent, as it does on /search.
Actual behavior
Observed 2026-08-26 against 2.30.0 with real embeddings (bge-m3):
No filter: /search returns false (five hits, all from the peer's fleet). /recall returns {"canary": true, "fleets": [five different fleet ids]}: the fleet-a row with its content, and rows from four other fleets in the tenant.
Filter naming the owner: /search returns 403. /recall returns 200 and true.
An owner-side control read of the same query finds the row on both paths, so the empty /search result is scope enforcement, not a missing row.
Relevant logs / config
Server: `MEMCLAW_API_KEY`set (compose override), `EMBEDDING_PROVIDER=openai` with `OPENAI_EMBEDDING_BASE_URL` at an LM Studio `bge-m3` endpoint, `ENTITY_EXTRACTION_PROVIDER=anthropic`. The result does not depend on the LLM tier; it reproduces with the fake provider too, as long as embeddings are real enough for the query to hit.
A PR follows with the same binding on /recall and four tests mirroring the /search ones in tests/test_route_authz_gaps.py.
Found while building an API conformance harness for a self-hosted assessment; the harness runs an owner-side control read beside every cross-agent read, which is how the /search and /recall difference showed up.
Pre-flight
I searched existing issues and Discussions for duplicates.
This is not a security vulnerability (those go through Security Advisories).
Caura version
2.30.0, 2.32.0
Deployment mode
Self-hosted (Docker Compose)
Operating system
Linux (WSL2 on Windows 11), Docker Desktop
Python version
3.12
PostgreSQL version
pgvector/pgvector:pg16
LLM provider in use
anthropic
Describe the bug
POST /api/v1/recallshares its request body withPOST /api/v1/search, but it never received the identity binding that #801 gave/search(H-14 in the 2026-06-11 audit, linked from #799). Two consequences for an agent credential, that is, a request withMEMCLAW_API_KEYset on the server andX-Agent-IDbound:/searchreturns 403 whenfilter_agent_idnames another agent./recallaccepts it, runs its trust<2 fleet forcing against the named agent, and uses that agent as the visibility identity./recallpassescaller_agent_id=body.filter_agent_idtosearch_memories, so with no filter it passesNone, which is the identity a tenant credential gets. The trust<2 fleet forcing also sits insideif body.filter_agent_id:, so it does not run either./searchbindseff_agent_id = body.filter_agent_id or auth.agent_idand forces the fleet for a trust-1 agent whether or not the filter is present.So a trust-1 agent in one fleet reads another fleet's
scope_teamrows through/recall, with content, while/searchrefuses the same request. The MCP twin,caura_recall, already passes the authenticatedagent_idascaller_agent_idand is not affected. Source:core-api/src/core_api/routes/memories.py,recall_endpoint(line 1960 on71e9981), compared withsearch(line 1658).Steps to reproduce
With
MEMCLAW_API_KEYset so thatX-Agent-IDis bound, andKEYholding that value.Ris any unique suffix.Expected behavior
/recallbehaves as/searchdoes on the same body:falsefrom both calls.filter_agent_id ... does not match the authenticated agent identity.A tenant or user credential (no bound agent) keeps full-tenant recall and may still filter by any agent, as it does on
/search.Actual behavior
Observed 2026-08-26 against 2.30.0 with real embeddings (bge-m3):
/searchreturnsfalse(five hits, all from the peer's fleet)./recallreturns{"canary": true, "fleets": [five different fleet ids]}: the fleet-a row with its content, and rows from four other fleets in the tenant./searchreturns403./recallreturns200andtrue.An owner-side control read of the same query finds the row on both paths, so the empty
/searchresult is scope enforcement, not a missing row.Relevant logs / config
Additional context
/searchand/stm/notes(routes/memories.py,routes/stm.py) and did not touchrecall_endpoint. Its comment block insearchdescribes exactly the two escalations above./recalland four tests mirroring the/searchones intests/test_route_authz_gaps.py./searchand/recalldifference showed up.Pre-flight