diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9587d5b6..19465a60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -607,7 +607,8 @@ export { } from "./sync-scope-protocol.js"; export { deriveTags, fileTags, normalizeTag } from "./tags.js"; // Test utilities (exported for consumer packages like viewer-server) -export { initTestSchema, insertTestSession } from "./test-utils.js"; +export type { MixedScopeFixture } from "./test-utils.js"; +export { initTestSchema, insertTestSession, seedMixedScopeFixture } from "./test-utils.js"; export type { Actor, Artifact, diff --git a/packages/core/src/scope-regression.test.ts b/packages/core/src/scope-regression.test.ts new file mode 100644 index 00000000..10ab0f5c --- /dev/null +++ b/packages/core/src/scope-regression.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connect, type Database } from "./db.js"; +import * as embeddings from "./embeddings.js"; +import { exportMemories } from "./export-import.js"; +import { buildMemoryPack } from "./pack.js"; +import { MemoryStore } from "./store.js"; +import type { MixedScopeFixture } from "./test-utils.js"; +import { initTestSchema, seedMixedScopeFixture } from "./test-utils.js"; +import { semanticSearch } from "./vectors.js"; + +vi.mock("./embeddings.js", async () => { + const actual = await vi.importActual("./embeddings.js"); + return { + ...actual, + embedTexts: vi.fn(), + getEmbeddingClient: vi.fn(), + resolveEmbeddingModel: vi.fn(() => "test-model"), + }; +}); + +describe("mixed-domain scope regression", () => { + let tmpDir: string; + let dbPath: string; + let store: MemoryStore; + let fixture: MixedScopeFixture; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = mkdtempSync(join(tmpdir(), "codemem-scope-regression-")); + dbPath = join(tmpDir, "test.sqlite"); + const db = connect(dbPath); + initTestSchema(db); + db.close(); + store = new MemoryStore(dbPath); + fixture = seedMixedScopeFixture(store.db, store.deviceId); + }); + + afterEach(() => { + store?.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("keeps unauthorized scope rows out of store, search, recent, timeline, and explain", () => { + const visibleIds = new Set(fixture.visibleIds); + + expect(store.get(fixture.personalId)?.title).toBe(fixture.visibleTitles[0]); + expect(store.get(fixture.authorizedId)?.title).toBe(fixture.visibleTitles[1]); + expect(store.get(fixture.unauthorizedId)).toBeNull(); + + for (const ids of [ + store.recent(10).map((item) => item.id), + store.search(fixture.query, 10).map((item) => item.id), + store.timeline(null, fixture.authorizedId, 5, 5).map((item) => item.id), + store.explain(fixture.query, fixture.allIds, 10).items.map((item) => item.id), + ]) { + expect(ids.some((id) => visibleIds.has(id))).toBe(true); + expect(ids).not.toContain(fixture.unauthorizedId); + } + expect(store.timeline(null, fixture.unauthorizedId, 5, 5)).toEqual([]); + + const explain = store.explain(null, fixture.allIds, 10); + expect(explain.items.map((item) => item.id).sort((a, b) => a - b)).toEqual( + [...fixture.visibleIds].sort((a, b) => a - b), + ); + expect(explain.missing_ids).toContain(fixture.unauthorizedId); + expect( + explain.errors.find((error) => error.code === "PROJECT_MISMATCH")?.ids ?? [], + ).not.toContain(fixture.unauthorizedId); + expect( + explain.errors.find((error) => error.code === "FILTER_MISMATCH")?.ids ?? [], + ).not.toContain(fixture.unauthorizedId); + }); + + it("keeps unauthorized scope rows out of semantic search", async () => { + insertTestVector(store.db, fixture.personalId, 0.3, "personal-vector"); + insertTestVector(store.db, fixture.unauthorizedId, 0, "hidden-vector"); + insertTestVector(store.db, fixture.authorizedId, 0.2, "authorized-vector"); + vi.mocked(embeddings.embedTexts).mockResolvedValue([new Float32Array(384)]); + + const results = await semanticSearch(store.db, fixture.query, 10, null, { + actorId: `local:${store.deviceId}`, + deviceId: store.deviceId, + }); + + const resultIds = results.map((item) => item.id); + expect(resultIds).toEqual(expect.arrayContaining(fixture.visibleIds)); + expect(resultIds).not.toContain(fixture.unauthorizedId); + }); + + it("keeps unauthorized scope rows out of pack text and exports", () => { + const pack = buildMemoryPack(store, fixture.query, 10); + expect(pack.item_ids.some((id) => fixture.visibleIds.includes(id))).toBe(true); + expect(pack.item_ids).not.toContain(fixture.unauthorizedId); + expect(pack.pack_text).toContain(fixture.visibleTitles[1]); + expect(pack.pack_text).not.toContain(fixture.unauthorizedTitle); + + const payload = exportMemories({ dbPath, allProjects: true }); + const exportedTitles = payload.memory_items.map((memory) => String(memory.title)); + expect(exportedTitles).toEqual(expect.arrayContaining(fixture.visibleTitles)); + expect(exportedTitles).not.toContain(fixture.unauthorizedTitle); + expect(payload.memory_items.map((memory) => memory.scope_id)).not.toContain( + fixture.unauthorizedScopeId, + ); + }); +}); + +function insertTestVector( + db: Database, + memoryId: number, + value: number, + contentHash: string, +): void { + const vector = new Float32Array(384).fill(value); + const vectorJson = JSON.stringify(Array.from(vector)); + const escapedVectorJson = vectorJson.replaceAll("'", "''"); + const escapedHash = contentHash.replaceAll("'", "''"); + db.exec(` + INSERT INTO memory_vectors(embedding, memory_id, chunk_index, content_hash, model) + VALUES (vec_f32('${escapedVectorJson}'), ${memoryId}, 0, '${escapedHash}', 'test-model') + `); +} diff --git a/packages/core/src/test-utils.ts b/packages/core/src/test-utils.ts index 4318721a..f94f6a02 100644 --- a/packages/core/src/test-utils.ts +++ b/packages/core/src/test-utils.ts @@ -4,6 +4,7 @@ import type { Database } from "./db.js"; import { bootstrapSchema } from "./schema-bootstrap.js"; +import { LOCAL_DEFAULT_SCOPE_ID } from "./scope-resolution.js"; /** * Create the full schema for test databases. @@ -25,5 +26,131 @@ export function insertTestSession(db: Database): number { return Number(info.lastInsertRowid); } +export interface MixedScopeFixture { + sessionId: number; + deviceId: string; + authorizedScopeId: string; + unauthorizedScopeId: string; + personalId: number; + authorizedId: number; + unauthorizedId: number; + visibleIds: number[]; + allIds: number[]; + visibleTitles: string[]; + unauthorizedTitle: string; + query: string; +} + +/** + * Seed one mixed-domain dataset for scope-leak regression tests. + * + * The local-default "personal" row and the authorized work row must be visible; + * the OSS row is in an active coordinator scope without local membership and + * must never appear through read/export surfaces. + */ +export function seedMixedScopeFixture(db: Database, deviceId = "local"): MixedScopeFixture { + const now = "2026-04-01T00:00:00.000Z"; + const authorizedScopeId = "fixture-work-authorized"; + const unauthorizedScopeId = "fixture-oss-hidden"; + const query = "mixedscopeleak"; + db.prepare( + "INSERT OR IGNORE INTO sync_device(device_id, public_key, fingerprint, created_at) VALUES (?, ?, ?, ?)", + ).run(deviceId, "fixture-public-key", "fixture-fingerprint", now); + db.prepare( + `INSERT OR REPLACE INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 'fixture-coordinator', 'fixture-group', 0, 'active', ?, ?)`, + ).run(authorizedScopeId, "Authorized work fixture", now, now); + db.prepare( + `INSERT OR REPLACE INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 'fixture-coordinator', 'fixture-group', 0, 'active', ?, ?)`, + ).run(unauthorizedScopeId, "Hidden OSS fixture", now, now); + db.prepare( + `INSERT OR REPLACE INTO scope_memberships( + scope_id, device_id, role, status, membership_epoch, + coordinator_id, group_id, updated_at + ) VALUES (?, ?, 'member', 'active', 0, 'fixture-coordinator', 'fixture-group', ?)`, + ).run(authorizedScopeId, deviceId, now); + + const session = db + .prepare( + `INSERT INTO sessions(started_at, cwd, project, user, tool_version, metadata_json, import_key) + VALUES (?, '/tmp/mixed-domain-fixture', 'mixed-domain-fixture', 'test-user', 'test', '{}', 'fixture-session')`, + ) + .run(now); + const sessionId = Number(session.lastInsertRowid); + const insertMemory = (input: { + title: string; + body: string; + kind: string; + createdAt: string; + scopeId: string; + importKey: string; + }) => { + const info = db + .prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, confidence, tags_text, active, + created_at, updated_at, metadata_json, rev, visibility, scope_id, import_key + ) VALUES (?, ?, ?, ?, 0.9, '', 1, ?, ?, '{}', 1, 'shared', ?, ?)`, + ) + .run( + sessionId, + input.kind, + input.title, + input.body, + input.createdAt, + input.createdAt, + input.scopeId, + input.importKey, + ); + return Number(info.lastInsertRowid); + }; + const personalTitle = "Personal mixed-domain fixture memory"; + const authorizedTitle = "Authorized work mixed-domain fixture memory"; + const unauthorizedTitle = "Hidden OSS mixed-domain fixture memory"; + const personalId = insertMemory({ + title: personalTitle, + body: `${query} personal visible context`, + kind: "discovery", + createdAt: "2026-04-01T00:00:01.000Z", + scopeId: LOCAL_DEFAULT_SCOPE_ID, + importKey: "fixture-memory-personal", + }); + const unauthorizedId = insertMemory({ + title: unauthorizedTitle, + body: `${query} hidden oss context`, + kind: "feature", + createdAt: "2026-04-01T00:00:02.000Z", + scopeId: unauthorizedScopeId, + importKey: "fixture-memory-hidden-oss", + }); + const authorizedId = insertMemory({ + title: authorizedTitle, + body: `${query} authorized work context`, + kind: "decision", + createdAt: "2026-04-01T00:00:03.000Z", + scopeId: authorizedScopeId, + importKey: "fixture-memory-authorized-work", + }); + return { + sessionId, + deviceId, + authorizedScopeId, + unauthorizedScopeId, + personalId, + authorizedId, + unauthorizedId, + visibleIds: [personalId, authorizedId], + allIds: [personalId, unauthorizedId, authorizedId], + visibleTitles: [personalTitle, authorizedTitle], + unauthorizedTitle, + query, + }; +} + // Re-export for test convenience export { MemoryStore } from "./store.js"; diff --git a/packages/mcp-server/src/memory-access.test.ts b/packages/mcp-server/src/memory-access.test.ts index ddae376a..3d6fb47e 100644 --- a/packages/mcp-server/src/memory-access.test.ts +++ b/packages/mcp-server/src/memory-access.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { MemoryStore } from "@codemem/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { connect } from "../../core/src/db.js"; -import { initTestSchema } from "../../core/src/test-utils.js"; +import { initTestSchema, seedMixedScopeFixture } from "../../core/src/test-utils.js"; import { forgetMemoryForMcp, getManyForMcp, @@ -62,6 +62,19 @@ describe("MCP memory access scope guards", () => { expect(getMemoryForMcp(store, authorizedId, { scope_id: "scope-b" })).toBe(null); }); + it("keeps mixed-domain unauthorized scope rows out of MCP direct reads", () => { + const fixture = seedMixedScopeFixture(store.db, store.deviceId); + + expect(getMemoryForMcp(store, fixture.personalId)?.title).toBe(fixture.visibleTitles[0]); + expect(getMemoryForMcp(store, fixture.authorizedId)?.title).toBe(fixture.visibleTitles[1]); + expect(getMemoryForMcp(store, fixture.unauthorizedId)).toBe(null); + expect( + getManyForMcp(store, fixture.allIds) + .map((item) => item.id) + .sort((a, b) => a - b), + ).toEqual([...fixture.visibleIds].sort((a, b) => a - b)); + }); + it("refuses to forget unauthorized or explicitly filtered-out memories", () => { const authorizedId = insertScopedMemory(store, { sessionId, diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 5ac5d077..81b485d7 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -17,6 +17,7 @@ import { insertTestSession, loadPublicKey, MemoryStore, + seedMixedScopeFixture, startMaintenanceJob, updateMaintenanceJob, VERSION, @@ -629,6 +630,47 @@ describe("viewer-server", () => { } }); + it("keeps mixed-domain unauthorized scope rows out of viewer direct surfaces", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const fixture = seedMixedScopeFixture(store.db, store.deviceId); + + const memoryRes = await app.request("/api/memory?limit=10"); + expect(memoryRes.status).toBe(200); + const memory = (await memoryRes.json()) as { + items: Array<{ id: number; title: string }>; + }; + expect(memory.items.map((item) => item.id)).toEqual( + expect.arrayContaining(fixture.visibleIds), + ); + expect(memory.items.map((item) => item.id)).not.toContain(fixture.unauthorizedId); + + const observationsRes = await app.request("/api/observations?limit=10"); + expect(observationsRes.status).toBe(200); + const observations = (await observationsRes.json()) as { + items: Array<{ id: number; title: string }>; + }; + expect(observations.items.map((item) => item.id)).toEqual( + expect.arrayContaining(fixture.visibleIds), + ); + expect(observations.items.map((item) => item.title)).not.toContain( + fixture.unauthorizedTitle, + ); + + const packRes = await app.request(`/api/pack?context=${fixture.query}&limit=10`); + expect(packRes.status).toBe(200); + const pack = (await packRes.json()) as { item_ids: number[]; pack_text: string }; + expect(pack.item_ids.some((id) => fixture.visibleIds.includes(id))).toBe(true); + expect(pack.item_ids).not.toContain(fixture.unauthorizedId); + expect(pack.pack_text).not.toContain(fixture.unauthorizedTitle); + } finally { + cleanup(); + } + }); + it("applies mine/theirs scope filters to observations", async () => { const { app, getStore, cleanup } = createTestApp(); try {