Skip to content

fix(scope): enforce AGENT_ID isolation on mem::search (closes #817)#821

Open
wyh0626 wants to merge 1 commit into
rohitg00:mainfrom
wyh0626:fix/817-mem-search-isolation
Open

fix(scope): enforce AGENT_ID isolation on mem::search (closes #817)#821
wyh0626 wants to merge 1 commit into
rohitg00:mainfrom
wyh0626:fix/817-mem-search-isolation

Conversation

@wyh0626

@wyh0626 wyh0626 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #817. AGENTMEMORY_AGENT_SCOPE=isolated filtered recall on mem::smart-search, /memories, /observations, and /sessions (PR #654), but mem::search applied no agent-scope filtering at all. It backs POST /agentmemory/search plus the MCP tools memory_recall and recall_context, so an isolated worker for agent B could read agent A's memories. memory_recall is a primary recall tool, so this leaked in normal agent usage, not just via a raw REST call.

Root cause: isolation in #654 (commit 1aec56a) was added per-function — smart-search.ts and the list endpoints got getAgentId() / isAgentScopeIsolated(), but src/functions/search.ts was never touched. The #654 CHANGELOG enumerates the filtered paths and omits mem::search, matching the code.

Fix

mem::search now mirrors mem::smart-search. The BM25 index doesn't carry agentId, so it over-fetches and post-filters on the loaded observation:

const isolated = isAgentScopeIsolated()
const explicitAgentId =
  typeof data.agentId === 'string' && data.agentId.trim().length > 0
    ? data.agentId.trim() : undefined
const filterAgentId = explicitAgentId === '*'
  ? undefined                                       // opt out of env default
  : explicitAgentId ?? (isolated ? getAgentId() : undefined)
  • api::search forwards agentId — body first, then ?agentId= — consistent with /memories, /observations, /sessions. When omitted, mem::search falls back to the env AGENT_ID.
  • memory_recall / recall_context forward agentId. recall_context also scopes its direct kv.list(KV.memories) pass, which previously filtered only by isLatest and leaked across agents.
  • memoryToObservation() now copies agentId. Memories saved via /remember are indexed through this helper; without it, isolated recall dropped the owner's own remembered memories — exactly the /remember/search path in the AGENT_ID isolated scope not enforced on mem::search (REST /search, memory_recall, recall_context) → cross-agent memory leak #817 repro.

Verify

Reproducer from #817:

# agent A writes
AGENT_ID=agent_a AGENTMEMORY_AGENT_SCOPE=isolated npx @agentmemory/agentmemory
curl -sX POST localhost:3111/agentmemory/remember -d '{"content":"SECRET_MARKER private to A"}'

# agent B, same data dir
AGENT_ID=agent_b AGENTMEMORY_AGENT_SCOPE=isolated npx @agentmemory/agentmemory
curl -sX POST localhost:3111/agentmemory/search -d '{"query":"SECRET_MARKER"}'
# before: returns A's memory
# after:  0 results

Unit:

npx vitest run test/search-agent-scope.test.ts
# 6/6 pass
npm run build
# Build complete

I also reverted each source change and confirmed the new tests fail on the pre-fix code (3 leak cases + the /remember Memory case), so they actually catch the regression.

Tests

6 cases in test/search-agent-scope.test.ts:

  • shared mode (default) returns hits from every agent
  • isolated mode returns only the env AGENT_ID's hits
  • isolated mode does not leak another agent's memory
  • explicit agentId override wins over the env default
  • wildcard agentId: "*" opts out of isolation
  • isolated mode scopes memories saved via KV.memories (the /remember path through memoryToObservation)

test/cross-project-isolation.test.ts: its partial config.js mock gained the now-imported isAgentScopeIsolated export.

Known limitations / follow-ups

  • Over-fetch false negatives. Like mem::smart-search, agent-filtered recall over-fetches BM25 hits then post-filters. With a very small limit and a large out-of-scope head, an in-scope match beyond the fetch window can be missed — a false negative, not a leak. A durable fix indexes agentId in BM25 or makes the over-fetch adaptive.
  • Per-call agentId override discoverability. The override is wired at the function and REST-body level (matching smart-search), but isn't exposed in the MCP tool schema or forwarded by the standalone MCP proxy. This mirrors smart-search's existing behavior; default env isolation is unaffected.

Summary by CodeRabbit

  • Improvements
    • Better agent-specific memory handling to improve multi-agent observation tracking and isolated memory search.
    • Added inline documentation to clarify behavior and filtering implications for saved memories.

@vercel

vercel Bot commented Jun 4, 2026

Copy link
Copy Markdown

@wyh0626 is attempting to deploy a commit to the rohitg00's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 226f08b1-1559-4645-ab8c-2ac11029cc73

📥 Commits

Reviewing files that changed from the base of the PR and between cc338e9 and 55443a6.

📒 Files selected for processing (1)
  • src/state/memory-utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/state/memory-utils.ts

📝 Walkthrough

Walkthrough

Adds an agentId property to the CompressedObservation returned by memoryToObservation, populated from memory.agentId to support agent-scoped filtering of remembered memories.

Changes

Agent observation enrichment

Layer / File(s) Summary
Observation enrichment with agent identifier
src/state/memory-utils.ts
memoryToObservation now includes agentId from memory.agentId in the returned observation object with inline comments describing filtering behavior for agent-isolated searches.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • rohitg00/agentmemory#654: Both PRs extend multi-agent agentId scoping by ensuring CompressedObservation carries agentId.
  • rohitg00/agentmemory#849: This change supplies obs.agentId used by agent-scope filtering in mem::search result enrichment.

Poem

I nibble on code and hop with glee,
Tiny change, now memories see,
Agent tags tucked in each observation,
Hopping through scoped recollection,
A rabbit cheers this tidy spree. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically describes the main change: enforcing AGENT_ID isolation on mem::search, with a clear reference to the issue being closed (#817). It accurately summarizes the core fix without being vague or misleading.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/search-agent-scope.test.ts (1)

156-162: 💤 Low value

Consider consolidating duplicate test coverage.

This test verifies the same isolation behavior as the previous test (lines 148-154), just with a negative assertion style. The previous test already confirms only obs_b is returned, which inherently proves obs_a is not leaked.

While explicit negative assertions for security properties have value, the redundancy adds minimal benefit.

💡 Optional consolidation

Consider either removing this test or combining it with the previous one:

  it("isolated mode returns only the env AGENT_ID's hits", async () => {
    process.env["AGENT_ID"] = "agent_b";
    process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";

    const { results } = await search({ query: "secret marker" });
    expect(results.map((r) => r.obsId)).toEqual(["obs_b"]);
+   // Verify no cross-agent leakage
+   expect(results.some((r) => r.obsId === "obs_a")).toBe(false);
  });

- it("isolated mode does not leak another agent's memory", async () => {
-   process.env["AGENT_ID"] = "agent_b";
-   process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";
-
-   const { results } = await search({ query: "secret marker" });
-   expect(results.some((r) => r.obsId === "obs_a")).toBe(false);
- });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/search-agent-scope.test.ts` around lines 156 - 162, The test "isolated
mode does not leak another agent's memory" duplicates the previous isolation
test's coverage; remove this redundant test or merge its negative assertion into
the preceding test so isolation is asserted once. Specifically, either delete
the "isolated mode does not leak another agent's memory" it(...) block in
test/search-agent-scope.test.ts, or update the prior test (the other it(...)
that checks returned results for only obs_b) to also assert results.some(r =>
r.obsId === "obs_a") is false, keeping a single test that verifies both positive
and negative expectations for isolation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/functions/search.ts`:
- Around line 380-381: Split the single "filtering" boolean into two flags: one
for session-based filtering (e.g., sessionFiltering = !!(projectFilter ||
cwdFilter)) and one for agent-only filtering (e.g., agentFiltering =
!!filterAgentId); keep a combined boolean (e.g., anyFiltering = sessionFiltering
|| agentFiltering) for calculating fetchLimit (replace the current filtering use
in fetchLimit with anyFiltering) but use sessionFiltering when deciding to load
sessions from KV.sessions (the block around the code previously at lines 414-416
that reads KV.sessions should only run if sessionFiltering is true), and leave
the agent check where it currently is (around line 464) using agentFiltering;
update all references to the old filtering variable accordingly.

In `@test/search-agent-scope.test.ts`:
- Around line 69-133: The test is bypassing TypeScript checks by passing mocks
with "as never" to registerSearchFunction; update the mock factories so
mockSdk() returns ISdk and mockKV() returns StateKV (or use a narrower assertion
like mockSdk() as unknown as ISdk) and then call registerSearchFunction(sdk, kv)
without "as never"; ensure mockSdk and mockKV signatures and returned objects
implement the methods used by registerSearchFunction so the compiler enforces
interface compatibility.

---

Nitpick comments:
In `@test/search-agent-scope.test.ts`:
- Around line 156-162: The test "isolated mode does not leak another agent's
memory" duplicates the previous isolation test's coverage; remove this redundant
test or merge its negative assertion into the preceding test so isolation is
asserted once. Specifically, either delete the "isolated mode does not leak
another agent's memory" it(...) block in test/search-agent-scope.test.ts, or
update the prior test (the other it(...) that checks returned results for only
obs_b) to also assert results.some(r => r.obsId === "obs_a") is false, keeping a
single test that verifies both positive and negative expectations for isolation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 250263de-cb8f-4fa6-8f17-5669c7af0601

📥 Commits

Reviewing files that changed from the base of the PR and between 334e5ad and 9ffe441.

📒 Files selected for processing (6)
  • src/functions/search.ts
  • src/mcp/server.ts
  • src/state/memory-utils.ts
  • src/triggers/api.ts
  • test/cross-project-isolation.test.ts
  • test/search-agent-scope.test.ts

Comment thread src/functions/search.ts
Comment thread test/search-agent-scope.test.ts Outdated
Comment on lines +69 to +133
beforeEach(async () => {
delete process.env["AGENT_ID"];
delete process.env["AGENTMEMORY_AGENT_SCOPE"];

sdk = mockSdk();
kv = mockKV();
registerSearchFunction(sdk as never, kv as never);

// Two sessions owned by two different agents, each with one matching
// observation. The query hits both, so agentId is the only thing that
// can separate them.
const sessionA: Session = {
id: "ses_a",
project: "demo",
cwd: "/tmp/demo",
startedAt: "2026-01-01T00:00:00Z",
status: "completed",
observationCount: 1,
agentId: "agent_a",
};
const sessionB: Session = {
id: "ses_b",
project: "demo",
cwd: "/tmp/demo",
startedAt: "2026-01-02T00:00:00Z",
status: "completed",
observationCount: 1,
agentId: "agent_b",
};
await kv.set(KV.sessions, sessionA.id, sessionA);
await kv.set(KV.sessions, sessionB.id, sessionB);

const obsA: CompressedObservation = {
id: "obs_a",
sessionId: "ses_a",
timestamp: "2026-01-01T00:00:00Z",
type: "decision",
title: "Secret marker alpha",
facts: ["alpha private fact"],
narrative: "secret marker private to agent_a",
concepts: ["secret"],
files: [],
importance: 8,
agentId: "agent_a",
};
const obsB: CompressedObservation = {
id: "obs_b",
sessionId: "ses_b",
timestamp: "2026-01-02T00:00:00Z",
type: "decision",
title: "Secret marker beta",
facts: ["beta private fact"],
narrative: "secret marker private to agent_b",
concepts: ["secret"],
files: [],
importance: 8,
agentId: "agent_b",
};
await kv.set(KV.observations("ses_a"), obsA.id, obsA);
await kv.set(KV.observations("ses_b"), obsB.id, obsB);

// Module-level SearchIndex singleton leaks across tests; reset so each
// case triggers a fresh rebuild from the mock KV.
getSearchIndex().clear();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider improving type safety of mock injection.

The test setup correctly seeds agent-scoped sessions and observations, and properly clears the SearchIndex singleton to prevent cross-test pollution. However, line 75 uses as never to bypass TypeScript's type checking when passing mocks to registerSearchFunction.

While this works, it could hide type mismatches if the mock implementations drift from the actual interfaces.

🔧 Suggested improvement

Consider typing the mocks to properly match ISdk and StateKV interfaces, or use a type assertion that preserves some checking:

-    registerSearchFunction(sdk as never, kv as never);
+    registerSearchFunction(sdk as any, kv as any);

Or better yet, properly type the mock factories to return the correct interfaces (though this may require additional method stubs).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/search-agent-scope.test.ts` around lines 69 - 133, The test is bypassing
TypeScript checks by passing mocks with "as never" to registerSearchFunction;
update the mock factories so mockSdk() returns ISdk and mockKV() returns StateKV
(or use a narrower assertion like mockSdk() as unknown as ISdk) and then call
registerSearchFunction(sdk, kv) without "as never"; ensure mockSdk and mockKV
signatures and returned objects implement the methods used by
registerSearchFunction so the compiler enforces interface compatibility.

@wyh0626 wyh0626 force-pushed the fix/817-mem-search-isolation branch 2 times, most recently from 674bf46 to cc338e9 Compare June 8, 2026 07:32
… follow-up)

The rohitg00#817 fix enforces AGENT_ID isolation in mem::search by post-filtering
loaded rows on `obs.agentId !== filterAgentId`. But memoryToObservation()
— the path that wraps /remember-saved Memory rows for the index — never
copied agentId, so memories load with agentId=undefined and get dropped
by the isolated-mode filter. Effect: in AGENTMEMORY_AGENT_SCOPE=isolated
an agent cannot recall its own remembered memories (false negative, not
a leak).

Copy agentId in memoryToObservation so isolation applies to memories too.
@wyh0626 wyh0626 force-pushed the fix/817-mem-search-isolation branch from cc338e9 to 55443a6 Compare June 8, 2026 08:18
@wyh0626

wyh0626 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Hi @rohitg00 — rebased onto latest main and trimmed this down to a one-liner, since the main #817 leak is already fixed in #849.

That fix filters on obs.agentId !== filterAgentId, but memoryToObservation() never copies agentId. So /remember-saved memories load with agentId === undefined and get dropped in isolated mode — meaning an agent can't recall its own memories.

// src/state/memory-utils.ts
    importance: memory.strength,
+   agentId: memory.agentId,

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.

AGENT_ID isolated scope not enforced on mem::search (REST /search, memory_recall, recall_context) → cross-agent memory leak

1 participant