Skip to content

[Bug] /recall does not bind the caller identity the way /search does #994

Description

@zznate

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/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:

  1. 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.
  2. 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:

  1. No filter: the trust-1 peer is forced to its own fleet and does not see the fleet-a row. false from both calls.
  2. 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):

  1. 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.
  2. 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.

Additional context

  • fix(core-api): bind read-path identity to the authenticated agent #801 added the binding to /search and /stm/notes (routes/memories.py, routes/stm.py) and did not touch recall_endpoint. Its comment block in search describes exactly the two escalations above.
  • 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugSomething isn't working as documented.status/needs-triageAwaiting initial triage. Auto-applied by issue templates.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions