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
Copy link
Copy Markdown
Contributor

@wyh0626 wyh0626 commented Jun 4, 2026

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

  • New Features

    • Search now supports agent scoping: limit recalls to a specific agent or use "*" to search across all agents.
    • /agentmemory/search and recall prompts accept an optional agent identifier parameter.
    • Memory results include agent identifiers so recalled items indicate their owning agent.
  • Tests

    • Added tests validating agent-scope isolation, overrides, and wildcard behavior.

@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 4, 2026

@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
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

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: 1677c586-12ce-4bbb-8c5e-e650aed4e58b

📥 Commits

Reviewing files that changed from the base of the PR and between 9ffe441 and 674bf46.

📒 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
✅ Files skipped from review due to trivial changes (1)
  • src/state/memory-utils.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/cross-project-isolation.test.ts
  • src/triggers/api.ts
  • test/search-agent-scope.test.ts
  • src/mcp/server.ts

📝 Walkthrough

Walkthrough

The PR adds optional agent-scoping to the mem::search function and related API/MCP handlers. Search now accepts an optional agentId parameter, computes an effective agent filter using environment-based isolation detection, and filters results by matching observation.agentId. Observations are enriched with agentId fields. The REST and MCP handler entry points accept and forward agent scoping parameters. A new test suite validates isolation behavior across shared/isolated modes, wildcard opt-out, and remembered memories.

Changes

Agent-scoped memory search and isolation

Layer / File(s) Summary
Observation enrichment with agent identifier
src/state/memory-utils.ts
Observations returned from memory-to-observation conversion now include an agentId field sourced from the memory record, providing agent context for downstream filtering.
Search input contract and imports
src/functions/search.ts
mem::search input schema extended with optional agentId?: string, and isAgentScopeIsolated/getAgentId imports added to support computing effective agent filtering.
Filter computation and fetch sizing
src/functions/search.ts
Computes filterAgentId from explicit agentId (supports '*' wildcard) or environment isolation via getAgentId()/isAgentScopeIsolated(), and enables over-fetching when agent filtering is active.
Candidate cap, session gating, and enrichment filtering
src/functions/search.ts
Candidate collection uses a higher cap when filterAgentId is set and gates session-based filtering to project/cwd; enrichment skips results whose obs.agentId doesn't match filterAgentId and truncates to the requested limit.
REST API handler for scoped search
src/triggers/api.ts
/agentmemory/search now accepts agentId from request body or query param, trims/truncates it, and conditionally includes it in the downstream mem::search payload.
MCP handler support for agent scoping
src/mcp/server.ts
MCP handlers import agent-scope helpers, forward agentId to mem::search only when explicitly provided, and recall_context filters returned memories by computed filterAgentId (supports wildcard opt-out).
Agent-scope isolation test suite and mocks
test/search-agent-scope.test.ts, test/cross-project-isolation.test.ts
New tests seed two agents and KV.memories to validate shared vs isolated behavior, explicit agentId override, wildcard '*' opt-out, and remembered-memory scoping; cross-project test mock adjusted to include isAgentScopeIsolated.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant Handler
  participant MemSearch as "mem::search"
  participant SearchIndex
  participant KV
  participant Enricher

  Caller->>Handler: request (optional agentId)
  Handler->>MemSearch: invoke mem::search (forward agentId only if explicit)
  MemSearch->>SearchIndex: query index (over-fetch candidates if agent filter)
  SearchIndex->>KV: load observation/memory blobs
  KV->>MemSearch: return raw observations
  MemSearch->>Enricher: enrich observations -> SearchResult (includes obs.agentId)
  Enricher->>MemSearch: enriched results
  MemSearch->>MemSearch: filter results where obs.agentId == filterAgentId
  MemSearch->>Handler: return filtered results
  Handler->>Caller: respond with scoped results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related issues

Possibly related PRs

  • rohitg00/agentmemory#654: Implements related AGENT_ID/agent-scope plumbing and API-layer propagation that this PR builds upon.

Suggested reviewers

  • rohitg00

Poem

🐰 A rabbit hops through memories deep,
Where agent walls keep secrets neat,
Each search now knows which warren's tale,
Stars of scope help paths not fail,
Wildcards let the tunnel greet.

🚥 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 accurately and specifically identifies the main change: enforcing AGENT_ID isolation on mem::search, addressing the security gap described in issue #817.
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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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

…0#817)

mem::search applied no agent-scope filtering, so the REST /search endpoint
and the memory_recall / recall_context MCP tools that route through it leaked
memories across agents under AGENTMEMORY_AGENT_SCOPE=isolated. Mirror the
mem::smart-search isolation logic: filter to the env AGENT_ID, support a
per-call agentId override, and agentId="*" opt-out, over-fetching BM25 hits
and post-filtering on the loaded observation.

- api::search forwards agentId (body, then ?agentId=)
- memory_recall / recall_context forward agentId; recall_context also scopes
  its direct kv.list(KV.memories) pass (previously isLatest-only)
- memoryToObservation copies agentId so /remember memories aren't dropped in
  isolated recall (the rohitg00#817 repro path)

Adds test/search-agent-scope.test.ts (6 cases) and gives the cross-project
config.js mock the now-imported isAgentScopeIsolated export.
@wyh0626 wyh0626 force-pushed the fix/817-mem-search-isolation branch from 9ffe441 to 674bf46 Compare June 4, 2026 08:38
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