Conversation
|
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 Two process notes: 1. Security-affecting changes. For anything with a security dimension, we'd rather you go through private vulnerability reporting, or 2. Before this can land:
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 |
|
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 For what it's worth, I validated your change against current
So it's just the rebase and dropping the |
|
Rebased onto Local run against this head: For the release notes, in one sentence: an agent credential that omits 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. |
aeced5f to
4bb33b8
Compare
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>
4bb33b8 to
9733014
Compare
|
Rebased onto |
|
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 Why I'm closing this rather than merging it. #1268 landed a shared 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 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 Two things you got right that we got wrong, and I'd rather say so than quietly fix them: The patch seam. You patched Asserting on arguments rather than results. Your test captures what reaches And your verification method was better than ours. You cherry-picked onto a 2.30.0 image and reproduced against real #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. |
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>
Summary
Bind
POST /recallto the authenticated agent the wayPOST /searchis bound: an agent credential may only filter to itself, and an omittedfilter_agent_idrecalls as the authenticated agent, not as the tenant. A tenant or user credential is unchanged.Related Issue
Closes #994
Type of Change
How Has This Been Tested?
Four tests in
tests/test_route_authz_gaps.py, next to the/searchones from #801 and using the sameas_authfixture:filter_agent_idis refused with 403 and the same message/searchgives;search_memorieswithcaller_agent_idequal to the authenticated agent andfilter_agent_idNone(the search call is patched at the module seam to capture its arguments);Verified live on a 2.30.0 image with this change cherry-picked, real
bge-m3embeddings, andMEMCLAW_API_KEYset soX-Agent-IDbinds. The reproduction from the issue now gives the/searchanswers on/recall: a trust-1 peer with no filter is forced to its own fleet and does not see another fleet'sscope_teamrow, 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
ruff checkandruff format --checkpassmypypassespytestpasses locallyCHANGELOG.mdunder theUnreleasedsection (if user-facing)Additional Notes
/searchblock moved to/recall, with the comment saying why, so the two read paths now read the same. The trust<2 fleet forcing andenforce_fleet_readrun against the effective identity, andsearch_memoriesgets it ascaller_agent_id.caura_recall, already passes the authenticatedagent_idascaller_agent_idand is unchanged./recallalready describes the/searchsemantics; this makes the route match it.CHANGELOG.mdhad noUnreleasedsection onmain; 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.