From ba540409f761084d1e5792db90519607d5d91b46 Mon Sep 17 00:00:00 2001 From: Adam Kunicki Date: Mon, 4 May 2026 21:00:44 -0700 Subject: [PATCH] test(plugin): guard scoped OpenCode context injection --- .../.opencode/tests/plugin-injection.test.js | 17 +- .../tests/plugin-transform-hook.test.js | 160 ++++++++++++++++++ packages/cli/.opencode/vitest.config.ts | 7 + 3 files changed, 178 insertions(+), 6 deletions(-) diff --git a/packages/cli/.opencode/tests/plugin-injection.test.js b/packages/cli/.opencode/tests/plugin-injection.test.js index dbb91968..b8b8f81b 100644 --- a/packages/cli/.opencode/tests/plugin-injection.test.js +++ b/packages/cli/.opencode/tests/plugin-injection.test.js @@ -109,20 +109,20 @@ describe("buildPackArgs", () => { }); describe("applyInjectedContextToOutput", () => { - test("recomputes pack on every call and toasts once per session", async () => { + test("recomputes pack on every call so same-session cache hits cannot cross scopes", async () => { const injectionToastShown = new Set(); const buildInjectedContext = vi .fn() .mockResolvedValueOnce({ - text: "[codemem context]\nfirst turn", + text: "[codemem context]\n## Summary\n[1] (decision) Authorized scope A", metrics: { items: 1, pack_tokens: 42, pack_delta_available: false }, }) .mockResolvedValueOnce({ - text: "[codemem context]\nsecond turn", + text: "[codemem context]\n## Summary\n[2] (decision) Authorized scope B", metrics: { items: 2, pack_tokens: 88, pack_delta_available: false }, }); const showToast = vi.fn().mockResolvedValue(undefined); - const resolveInjectQuery = vi.fn().mockReturnValue("auth fix codemem"); + const resolveInjectQuery = vi.fn().mockReturnValue("same prompt after scope switch"); const firstOutput = {}; const firstApplied = await __testUtils.applyInjectedContextToOutput({ @@ -148,8 +148,13 @@ describe("applyInjectedContextToOutput", () => { expect(firstApplied).toBe(true); expect(secondApplied).toBe(true); - expect(firstOutput.system).toEqual(["[codemem context]\nfirst turn"]); - expect(secondOutput.system).toEqual(["[codemem context]\nsecond turn"]); + expect(firstOutput.system).toEqual([ + "[codemem context]\n## Summary\n[1] (decision) Authorized scope A", + ]); + expect(secondOutput.system).toEqual([ + "[codemem context]\n## Summary\n[2] (decision) Authorized scope B", + ]); + expect(secondOutput.system.join("\n")).not.toContain("Authorized scope A"); expect(buildInjectedContext).toHaveBeenCalledTimes(2); expect(showToast).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/.opencode/tests/plugin-transform-hook.test.js b/packages/cli/.opencode/tests/plugin-transform-hook.test.js index f48de8ac..ae20aee6 100644 --- a/packages/cli/.opencode/tests/plugin-transform-hook.test.js +++ b/packages/cli/.opencode/tests/plugin-transform-hook.test.js @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect } from "../../../core/src/db.js"; +import { initTestSchema } from "../../../core/src/test-utils.js"; const spawnMock = vi.fn(); const execSyncMock = vi.fn(() => "test-version"); @@ -25,8 +30,98 @@ const makeProcess = ({ stdout = "", stderr = "", exitCode = 0 }) => { return proc; }; +const makeProcessFromPackCommand = (args, options = {}) => { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + write: vi.fn(), + end: vi.fn(), + }; + queueMicrotask(async () => { + const stdout = []; + const stderr = []; + const originalCwd = process.cwd(); + const originalExitCode = process.exitCode; + const originalLog = console.log; + const originalError = console.error; + try { + const cwd = options.cwd; + if (cwd) process.chdir(cwd); + process.exitCode = 0; + console.log = (...values) => { + stdout.push(values.join(" ")); + }; + console.error = (...values) => { + stderr.push(values.join(" ")); + }; + + const packIndex = args.indexOf("pack"); + if (packIndex < 0) throw new Error(`pack command missing from ${args.join(" ")}`); + const { packCommand } = await import("../../src/commands/pack.js"); + await packCommand.parseAsync(args.slice(packIndex + 1), { from: "user" }); + + const out = stdout.length > 0 ? `${stdout.join("\n")}\n` : ""; + const err = stderr.length > 0 ? `${stderr.join("\n")}\n` : ""; + if (out) proc.stdout.emit("data", out); + if (err) proc.stderr.emit("data", err); + proc.emit("exit", typeof process.exitCode === "number" ? process.exitCode : 0); + } catch (error) { + proc.stderr.emit("data", error instanceof Error ? error.message : String(error)); + proc.emit("exit", 1); + } finally { + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + if (process.cwd() !== originalCwd) process.chdir(originalCwd); + } + }); + return proc; +}; + +const insertSession = (db, { cwd, project }) => { + const now = new Date().toISOString(); + const info = db + .prepare("INSERT INTO sessions(started_at, cwd, project, user, tool_version) VALUES (?, ?, ?, ?, ?)") + .run(now, cwd, project, "plugin-test", "test"); + return Number(info.lastInsertRowid); +}; + +const insertCoordinatorScope = (db, scopeId) => { + const now = new Date().toISOString(); + 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', 'coord-test', 'group-test', 0, 'active', ?, ?)`, + ).run(scopeId, scopeId, now, now); +}; + +const grantScopeToDevice = (db, scopeId, deviceId) => { + insertCoordinatorScope(db, scopeId); + 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, 'coord-test', 'group-test', ?)`, + ).run(scopeId, deviceId, new Date().toISOString()); +}; + +const insertScopedMemory = ( + db, + { sessionId, scopeId, title, bodyText }, +) => { + const now = new Date().toISOString(); + 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) + VALUES (?, 'discovery', ?, ?, 0.9, '', 1, ?, ?, '{}', 1, 'shared', ?)`, + ).run(sessionId, title, bodyText, now, now, scopeId); +}; + describe("experimental.chat.system.transform", () => { const originalEnv = { ...process.env }; + const tmpDirs = []; beforeEach(() => { vi.resetModules(); @@ -42,6 +137,9 @@ describe("experimental.chat.system.transform", () => { }); afterEach(() => { + for (const tmpDir of tmpDirs.splice(0)) { + rmSync(tmpDir, { recursive: true, force: true }); + } process.env = originalEnv; }); @@ -83,4 +181,66 @@ describe("experimental.chat.system.transform", () => { ]); expect(spawnMock).toHaveBeenCalledTimes(1); }); + + test("injects the CLI-scoped pack without unauthorized scope memories", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "codemem-plugin-scope-")); + tmpDirs.push(tmpDir); + const worktree = join(tmpDir, "greenroom"); + mkdirSync(worktree); + const dbPath = join(tmpDir, "mem.sqlite"); + const deviceId = "plugin-scope-device"; + const db = connect(dbPath); + initTestSchema(db); + const sessionId = insertSession(db, { cwd: worktree, project: "greenroom" }); + grantScopeToDevice(db, "scope-a", deviceId); + insertCoordinatorScope(db, "scope-b"); + insertScopedMemory(db, { + sessionId, + scopeId: "scope-a", + title: "Greenroom authorized scope note", + bodyText: "greenroom scope safety can use the authorized deployment note", + }); + insertScopedMemory(db, { + sessionId, + scopeId: "scope-b", + title: "Greenroom forbidden payroll secret", + bodyText: "greenroom scope safety must not inject forbidden payroll details", + }); + db.close(); + + process.env.CODEMEM_DB = dbPath; + process.env.CODEMEM_DEVICE_ID = deviceId; + process.env.CODEMEM_RUNNER = "codemem-test-runner"; + const showToast = vi.fn().mockResolvedValue(undefined); + spawnMock.mockImplementation((_command, args, options) => { + if (Array.isArray(args) && args.includes("pack")) { + return makeProcessFromPackCommand(args, options); + } + return makeProcess({ stdout: "" }); + }); + + const { OpencodeMemPlugin } = await import("../plugins/codemem.js"); + const hooks = await OpencodeMemPlugin({ + project: { name: "greenroom" }, + client: { + app: { log: vi.fn().mockResolvedValue(undefined) }, + tui: { showToast }, + }, + directory: worktree, + worktree, + }); + + const output = { system: ["base system prompt"] }; + await hooks["experimental.chat.system.transform"]( + { sessionID: "sess-scope-a", model: {} }, + output, + ); + + const systemPrompt = output.system.join("\n"); + expect(systemPrompt).toContain("Greenroom authorized scope note"); + expect(systemPrompt).not.toContain("Greenroom forbidden payroll secret"); + expect(systemPrompt).not.toContain("forbidden payroll details"); + expect(showToast).toHaveBeenCalledTimes(1); + expect(JSON.stringify(showToast.mock.calls)).not.toContain("forbidden payroll"); + }); }); diff --git a/packages/cli/.opencode/vitest.config.ts b/packages/cli/.opencode/vitest.config.ts index 95c70f9b..d6bfa767 100644 --- a/packages/cli/.opencode/vitest.config.ts +++ b/packages/cli/.opencode/vitest.config.ts @@ -1,6 +1,13 @@ +import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@codemem/core": path.resolve(import.meta.dirname, "../../core/src/index.ts"), + }, + conditions: ["source"], + }, test: { name: "cli-plugin", include: [".opencode/tests/**/*.test.js"],