Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions packages/core/src/scope-regression.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("./embeddings.js")>("./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')
`);
}
127 changes: 127 additions & 0 deletions packages/core/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
15 changes: 14 additions & 1 deletion packages/mcp-server/src/memory-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions packages/viewer-server/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
insertTestSession,
loadPublicKey,
MemoryStore,
seedMixedScopeFixture,
startMaintenanceJob,
updateMaintenanceJob,
VERSION,
Expand Down Expand Up @@ -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 {
Expand Down