Skip to content

fix(api): bind /recall to the authenticated agent the way /search does - #995

Closed
zznate wants to merge 1 commit into
caura-ai:mainfrom
zznate:fix/recall-caller-identity
Closed

zznate wants to merge 1 commit into
caura-ai:mainfrom
zznate:fix/recall-caller-identity

Conversation

@zznate

@zznate zznate commented Aug 25, 2026

Copy link
Copy Markdown

Summary

Bind POST /recall to the authenticated agent the way POST /search is bound: an agent credential may only filter to itself, and an omitted filter_agent_id recalls as the authenticated agent, not as the tenant. A tenant or user credential is unchanged.

Related Issue

Closes #994

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Documentation update
  • Refactor / internal cleanup
  • Other:

How Has This Been Tested?

Four tests in tests/test_route_authz_gaps.py, next to the /search ones from #801 and using the same as_auth fixture:

  • a peer in filter_agent_id is refused with 403 and the same message /search gives;
  • filtering to the agent's own id is allowed;
  • an omitted filter reaches search_memories with caller_agent_id equal to the authenticated agent and filter_agent_id None (the search call is patched at the module seam to capture its arguments);
  • a tenant credential with no bound agent may still filter by any agent.
core-api/.venv/bin/ruff check core-api/src/core_api/routes/memories.py tests/test_route_authz_gaps.py   # All checks passed
core-api/.venv/bin/ruff format --check core-api/src/core_api/routes/memories.py tests/test_route_authz_gaps.py
core-api/.venv/bin/pytest tests/                                                                        # 5304 passed

Verified live on a 2.30.0 image with this change cherry-picked, real bge-m3 embeddings, and MEMCLAW_API_KEY set so X-Agent-ID binds. The reproduction from the issue now gives the /search answers on /recall: a trust-1 peer with no filter is forced to its own fleet and does not see another fleet's scope_team row, and a filter naming the owner returns 403. An external conformance check that runs an owner-side control read beside the peer read passes on both paths.

Checklist

  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes (or explained why none are needed)
  • ruff check and ruff format --check pass
  • mypy passes
  • pytest passes locally
  • I have updated relevant documentation (README, docs, etc.)
  • I have updated CHANGELOG.md under the Unreleased section (if user-facing)

Additional Notes

  • The change is the /search block moved to /recall, with the comment saying why, so the two read paths now read the same. The trust<2 fleet forcing and enforce_fleet_read run against the effective identity, and search_memories gets it as caller_agent_id.
  • The MCP twin, caura_recall, already passes the authenticated agent_id as caller_agent_id and is unchanged.
  • No documentation change was needed: the public API contract for /recall already describes the /search semantics; this makes the route match it.
  • CHANGELOG.md had no Unreleased section on main; this adds one above 2.32.0. If feat(common): allow a self-hosted base URL for the chat LLM path #993 lands first, the two entries merge under the same heading.

@zznate
zznate requested a review from a team as a code owner August 25, 2026 21:10
@erni-a

erni-a commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Thanks for this — and for including the tenant-credential case in the tests. That's the one people usually leave out, and it's what makes the change reviewable rather than just plausible.

I've verified the reasoning against main rather than taking it on faith: /search already carries this binding (its own comments describe it as closing "the search side of the BOLA chain"), and the MCP caura_recall path resolves identity from the authenticated agent, so the REST /recall handler was the outlier. Your change lines it up with both, and the eff_agent_id fallback keeps tenant/user credentials behaving as they do today.

Two process notes:

1. Security-affecting changes. For anything with a security dimension, we'd rather you go through private vulnerability reporting, or security@caura.ai as a fallback — see SECURITY.md. It lets us prepare the patch and a public advisory together, and you get credited in the advisory. No issue at all this time, and I'm happy to credit you in one for this if you'd like it — just letting you know the door exists.

2. Before this can land:

  • Rebase onto main. The PR is conflicting right now. Most relevantly, fix(api): /recall and /ingest/commit 500 under headers_enabled=True (D14 follow-up) #984 added a response: Response parameter to recall_endpoint (slowapi needs it to inject rate-limit headers, and its absence was 500ing the route) in the same region you're editing. A fair amount else has landed since you opened this.
  • Drop the CHANGELOG.md edit. That file is generated by release-please from Conventional Commit subjects — see changelog-path in release-please-config.json — so a hand-written entry will duplicate or conflict with the generated one. Your commit subject already carries it.

I'm approving the workflows so CI runs, and I'll take it through review once it's rebased and green.

One behavioural note worth capturing in the release notes rather than the code: an agent credential that currently omits filter_agent_id and relies on tenant-wide recall will start getting fleet-scoped results after this. That's the fix doing its job, but it will look like a change to anyone who built against the old behaviour.

@erni-a

erni-a commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

CI has run now, and the one red check is not your change — it's the rebase staleness, so don't go chasing it.

The failing gate is Broker OpenAPI has no breaking change vs main. It regenerates the broker spec from the branch's code and diffs it against the copy committed on main. The last commit to touch that spec is #1005, merged a few hours ago, which your branch predates — so the diff reports properties on GET /api/v1/health (version, platform_init_errors, unhealthy_dependencies) as "removed" when in fact your branch simply doesn't have the code that produces them yet. A rebase clears it.

For what it's worth, I validated your change against current main locally rather than waiting on that:

  • Applies cleanly onto today's main (the "conflicting" flag was computed before the recent merges).
  • It correctly preserves fix(api): /recall and /ingest/commit 500 under headers_enabled=True (D14 follow-up) #984's response: Response parameter on recall_endpoint, and the binding lands inside recall_endpoint rather than being absorbed into /search's existing eff_agent_id block — both things I specifically checked, since that region has moved a lot today.
  • Ruff (CI's exact scopes), the legacy-name ratchet and the do-not-touch sentinel all clean; 67 tests green across the authz, recall, rate-limit and MCP suites; full-suite delta against main is zero.
  • Reverting only memories.py and keeping your tests fails exactly test_recall_cannot_borrow_a_peer_identity_via_filter_agent_id and test_recall_binds_an_omitted_filter_to_the_authenticated_agent, while both no-regression tests still pass. That's the right two failing for the right reason — the tests earn their keep.

So it's just the rebase and dropping the CHANGELOG.md hunk. If you'd rather not, say the word — maintainerCanModify is on, so we can do the rebase on your branch and keep your authorship rather than let a fix like this sit.

@zznate

zznate commented Aug 26, 2026

Copy link
Copy Markdown
Author

Rebased onto main at d82eebd (head 4bb33b8) and dropped the CHANGELOG.md hunk; the commit subject carries the note. #984's response: Response parameter on recall_endpoint is kept, and the binding sits inside recall_endpoint as before.

Local run against this head: tests/test_route_authz_gaps.py 28 passed; full suite 5363 passed, 16 failed, the same 16 you see on main (test_atomic_fact_unembedded_persist, test_crystallization_must_not_wedge, test_embed_off_hot_path and friends), none in the authz, recall, rate-limit, or MCP suites.

For the release notes, in one sentence: an agent credential that omits filter_agent_id and relied on tenant-wide recall now gets fleet-scoped results, the same as /search.

Noted on the security process; the next report of this kind goes through private vulnerability reporting first. Credit in an advisory is welcome, thank you.

@zznate
zznate force-pushed the fix/recall-caller-identity branch from aeced5f to 4bb33b8 Compare August 26, 2026 10:27
POST /recall is the sibling of POST /search and shares its request body,
but it never received the H-14 identity binding that /search got. It ran
the trust<2 fleet forcing against whatever filter_agent_id the caller
asserted, and when an agent credential omitted the filter it passed
caller_agent_id=None to the search, which is the tenant-wide visibility a
tenant credential gets. Either way a trust-1 agent in one fleet read
another fleet's scope_team rows through /recall while /search refused the
same request with 403.

/recall now does what /search does: an agent credential may only filter
to itself (403 otherwise, same message), and the effective identity is
filter_agent_id with a fall back to auth.agent_id. That identity feeds
the fleet forcing, the fleet read check, and the search's
caller_agent_id. A tenant or user credential (auth.agent_id None) keeps
full-tenant recall and may still filter by any agent, as before. The MCP
twin, caura_recall, already bound to the authenticated agent and is
unchanged.

Four tests mirror the /search ones: a peer filter is refused, a
self filter is allowed, an omitted filter reaches search_memories as the
authenticated agent, and a tenant credential keeps filtering by any
agent.

Signed-off-by: zznate <zznate.m@gmail.com>
@zznate
zznate force-pushed the fix/recall-caller-identity branch from 4bb33b8 to 9733014 Compare August 27, 2026 17:50
@zznate

zznate commented Aug 27, 2026 •

Copy link
Copy Markdown
Author

Rebased onto main at 38649e1 (head 9733014, the same single commit, no conflicts). Local run from a clean worktree: tests/test_route_authz_gaps.py 28 passed; full suite 5444 passed, 16 failed, the same 16 that fail on main at 38649e1 (test_integration_search, test_p3_2_relation_weights, test_a7_classifier_recall, pipeline/test_search_pipeline, test_ph6_entity_linking_storage), none in the authz, recall, rate-limit, or MCP suites.

@Eldad-Caura

Copy link
Copy Markdown
Member

Thank you for this, and I'm sorry it sat for ten days while we shipped our own fix for the same bug without referencing yours. That's on us, not on you.

Your diagnosis was correct in full. #994 is one of the better bug reports this repo has received: exact source line references on a named commit, a working reproduction, the right root cause, and the observation that the MCP twin caura_recall was already bound and therefore unaffected. You also identified both consequences separately — the unchecked filter and the omitted-filter case passing caller_agent_id=None — and it's the second one that turned out to matter most, for reasons below.

Why I'm closing this rather than merging it. #1268 landed a shared _resolve_read_identity used by both /search and /recall, which covers everything this PR does plus one vector it doesn't: /recall parses SearchRequest, so caller_agent_id is also a caller-settable identity field. Guarding only filter_agent_id leaves the same escalation reachable by a different spelling. That's the sole substantive difference — your handling of the two consequences you reported is equivalent to what shipped, and you matched /search's error message exactly.

The shared-resolver shape mattered for a second reason: two routes parsing the same request model had drifted on what its fields mean, and that drift is the bug class. One implementation is what stops it recurring, which is why we went that way rather than adding a parallel check in /recall.

Your test is being merged, in #1276. Our five tests all named an identity explicitly, so the default path — no filter, no caller id, the ordinary recall — went unpinned. That's the case that needs no crafted request, and it's the consequence you flagged second. The behaviour is correct on main; nothing was holding it there. Your test now does, with attribution to you and to this PR.

Two things you got right that we got wrong, and I'd rather say so than quietly fix them:

The patch seam. You patched search_memories on memory_service. I first patched it on the route module — the binding /search uses — not realising /recall re-imports the function inside the handler and so resolves it at call time from the service module. My version silently no-op'd: the real search ran and the assertions read whatever that call left behind. It passed for the wrong reason until a removal probe caught it. #1276 carries a comment explaining the seam so nobody repeats it.

Asserting on arguments rather than results. Your test captures what reaches search_memories. Ours asserted result-set parity, which cannot distinguish "bound to the caller" from "happened to return the same rows" — those coincide whenever the caller has nothing hidden from it. Yours is the stronger assertion for an identity contract.

And your verification method was better than ours. You cherry-picked onto a 2.30.0 image and reproduced against real bge-m3 embeddings end to end. We verified with unit tests against a fake provider.

#994 is closed by #1268. If you hit anything else in the read paths, please do open it — this was a real high-severity finding and you found it from the outside.

@Eldad-Caura Eldad-Caura closed this Sep 4, 2026
Eldad-Caura added a commit that referenced this pull request Sep 4, 2026
Adds the one test #1268 should have had, from
[@zznate](https://github.com/zznate)'s
[#995](#995).

## The gap

#1268 fixed `/recall`'s identity resolution and shipped five tests.
**Every one of them names an identity explicitly** — a spoofed
`filter_agent_id`, a spoofed `caller_agent_id`, an agent naming itself,
a tenant key naming a peer, `caller_agent_id` honoured. 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](#994)), because it needs
no crafted request. 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.

The behaviour is correct on `main` today. It just was not held there by
anything.

## Confirmed, not assumed

Reverting only the `/recall` callsite to the pre-fix derivation:

```
AssertionError: an omitted filter did not bind to the authenticated agent:
  caller_agent_id=None, expected 'agent-a-5f54f57c'
```

`None` is precisely the tenant-wide identity the finding described.

## Asserted on arguments, not on results

The test captures what reaches `search_memories` rather 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

`/recall` re-imports the function **inside the handler**:

```python
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 removal probe exposed it. #995 patched the `memory_service`
seam 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 `/recall` accepts because it parses `SearchRequest`), which is why
#995 is superseded rather than merged. This test is not.

## Verification

- Full root suite: **6049 passed, 5 skipped, 1 xfailed, 0 failed**, run
as `.venv/bin/python -m pytest`. The diff is one new test function.
- `ruff check` and `ruff format --check` at CI's `tests/` scope — clean.
- `legacy_name_ratchet.py` → *No new lines.* ·
`do_not_touch_sentinel.py` → *All 39 protected strings survive.* Both
after `git add`.
- Branched fresh from `origin/main` at `7c94e1db`.
- Test-only, so `claude-review`'s source-file filter will skip this PR
by design.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Eldad Caura <eldad@caura.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

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

3 participants