-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(scope): enforce AGENT_ID isolation on mem::search (closes #817) #821
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
wyh0626
wants to merge
1
commit into
rohitg00:main
Choose a base branch
from
wyh0626:fix/817-mem-search-isolation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+279
−7
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| vi.mock("../src/logger.js", () => ({ | ||
| logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | ||
| })); | ||
|
|
||
| import { registerSearchFunction, getSearchIndex } from "../src/functions/search.js"; | ||
| import { KV } from "../src/state/schema.js"; | ||
| import type { CompressedObservation, Memory, Session } from "../src/types.js"; | ||
|
|
||
| function mockKV() { | ||
| const store = new Map<string, Map<string, unknown>>(); | ||
| return { | ||
| get: async <T>(scope: string, key: string): Promise<T | null> => | ||
| (store.get(scope)?.get(key) as T) ?? null, | ||
| set: async <T>(scope: string, key: string, data: T): Promise<T> => { | ||
| if (!store.has(scope)) store.set(scope, new Map()); | ||
| store.get(scope)!.set(key, data); | ||
| return data; | ||
| }, | ||
| delete: async (scope: string, key: string): Promise<void> => { | ||
| store.get(scope)?.delete(key); | ||
| }, | ||
| list: async <T>(scope: string): Promise<T[]> => { | ||
| const entries = store.get(scope); | ||
| return entries ? (Array.from(entries.values()) as T[]) : []; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function mockSdk() { | ||
| const functions = new Map<string, Function>(); | ||
| return { | ||
| registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { | ||
| const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; | ||
| functions.set(id, handler); | ||
| }, | ||
| registerTrigger: () => {}, | ||
| trigger: async ( | ||
| idOrInput: string | { function_id: string; payload: unknown }, | ||
| data?: unknown, | ||
| ) => { | ||
| const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; | ||
| const payload = typeof idOrInput === "string" ? data : idOrInput.payload; | ||
| const fn = functions.get(id); | ||
| if (!fn) throw new Error(`No function: ${id}`); | ||
| return fn(payload); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| // #817: mem::search must honor AGENTMEMORY_AGENT_SCOPE=isolated so the | ||
| // REST /search endpoint and the memory_recall / recall_context MCP tools | ||
| // (which all route through mem::search) don't leak memories across agents. | ||
| describe("mem::search agent-scope isolation (#817)", () => { | ||
| const ORIG_ID = process.env["AGENT_ID"]; | ||
| const ORIG_MODE = process.env["AGENTMEMORY_AGENT_SCOPE"]; | ||
|
|
||
| let sdk: ReturnType<typeof mockSdk>; | ||
| let kv: ReturnType<typeof mockKV>; | ||
|
|
||
| async function search(payload: Record<string, unknown>) { | ||
| return (await sdk.trigger("mem::search", { | ||
| format: "compact", | ||
| ...payload, | ||
| })) as { results: Array<{ obsId: string }> }; | ||
| } | ||
|
|
||
| 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(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| if (ORIG_ID === undefined) delete process.env["AGENT_ID"]; | ||
| else process.env["AGENT_ID"] = ORIG_ID; | ||
| if (ORIG_MODE === undefined) delete process.env["AGENTMEMORY_AGENT_SCOPE"]; | ||
| else process.env["AGENTMEMORY_AGENT_SCOPE"] = ORIG_MODE; | ||
| }); | ||
|
|
||
| it("shared mode (default) returns hits from every agent", async () => { | ||
| const { results } = await search({ query: "secret marker" }); | ||
| const ids = results.map((r) => r.obsId).sort(); | ||
| expect(ids).toEqual(["obs_a", "obs_b"]); | ||
| }); | ||
|
|
||
| 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"]); | ||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
|
|
||
| it("explicit agentId override wins over the env default", async () => { | ||
| process.env["AGENT_ID"] = "agent_b"; | ||
| process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated"; | ||
|
|
||
| const { results } = await search({ query: "secret marker", agentId: "agent_a" }); | ||
| expect(results.map((r) => r.obsId)).toEqual(["obs_a"]); | ||
| }); | ||
|
|
||
| it("wildcard agentId '*' opts out of isolation and returns all agents", async () => { | ||
| process.env["AGENT_ID"] = "agent_b"; | ||
| process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated"; | ||
|
|
||
| const { results } = await search({ query: "secret marker", agentId: "*" }); | ||
| const ids = results.map((r) => r.obsId).sort(); | ||
| expect(ids).toEqual(["obs_a", "obs_b"]); | ||
| }); | ||
|
|
||
| // The #817 repro uses /remember, not raw observations. Remembered | ||
| // Memory rows are indexed via memoryToObservation(); agentId must | ||
| // survive that conversion or isolated recall drops the owner's own | ||
| // memories (and the cross-agent filter can't distinguish them). | ||
| it("isolated mode scopes memories saved via KV.memories by agentId", async () => { | ||
| const memA: Memory = { | ||
| id: "mem_a", | ||
| title: "Remembered marker alpha", | ||
| content: "secret marker remembered by agent_a", | ||
| type: "fact", | ||
| concepts: ["secret"], | ||
| files: [], | ||
| strength: 1, | ||
| createdAt: "2026-01-03T00:00:00Z", | ||
| isLatest: true, | ||
| agentId: "agent_a", | ||
| } as Memory; | ||
| const memB: Memory = { | ||
| id: "mem_b", | ||
| title: "Remembered marker beta", | ||
| content: "secret marker remembered by agent_b", | ||
| type: "fact", | ||
| concepts: ["secret"], | ||
| files: [], | ||
| strength: 1, | ||
| createdAt: "2026-01-04T00:00:00Z", | ||
| isLatest: true, | ||
| agentId: "agent_b", | ||
| } as Memory; | ||
| await kv.set(KV.memories, memA.id, memA); | ||
| await kv.set(KV.memories, memB.id, memB); | ||
| getSearchIndex().clear(); | ||
|
|
||
| process.env["AGENT_ID"] = "agent_b"; | ||
| process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated"; | ||
|
|
||
| const { results } = await search({ query: "secret marker remembered" }); | ||
| const ids = results.map((r) => r.obsId); | ||
| expect(ids).toContain("mem_b"); // own remembered memory still recalled | ||
| expect(ids).not.toContain("mem_a"); // other agent's is not leaked | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 neverto bypass TypeScript's type checking when passing mocks toregisterSearchFunction.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
ISdkandStateKVinterfaces, or use a type assertion that preserves some checking:Or better yet, properly type the mock factories to return the correct interfaces (though this may require additional method stubs).
🤖 Prompt for AI Agents