diff --git a/src/index.ts b/src/index.ts index a3b4b07..90b531a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import type { Plugin, Hooks } from "@opencode-ai/plugin"; +import { join } from "path"; import { load, config } from "./config"; import { ensureProject, isFirstRun } from "./db"; import * as temporal from "./temporal"; @@ -67,6 +68,14 @@ export function buildRecoveryMessage( ].join("\n"); } +/** + * Check whether a project path is valid for file operations (e.g. AGENTS.md export/import). + * Returns false for root ("/"), empty, or falsy paths to prevent writing to the filesystem root. + */ +export function isValidProjectPath(p: string): boolean { + return !!p && p !== "/"; +} + export const LorePlugin: Plugin = async (ctx) => { const projectPath = ctx.worktree || ctx.directory; try { @@ -88,8 +97,8 @@ export const LorePlugin: Plugin = async (ctx) => { // (hand-written entries, edits from other machines, or merge conflicts). { const cfg = config(); - if (cfg.knowledge.enabled && cfg.agentsFile.enabled) { - const filePath = `${projectPath}/${cfg.agentsFile.path}`; + if (isValidProjectPath(projectPath) && cfg.knowledge.enabled && cfg.agentsFile.enabled) { + const filePath = join(projectPath, cfg.agentsFile.path); if (shouldImport({ projectPath, filePath })) { try { importFromFile({ projectPath, filePath }); @@ -424,8 +433,8 @@ export const LorePlugin: Plugin = async (ctx) => { // Export curated knowledge to AGENTS.md after distillation + curation. try { const agentsCfg = cfg.agentsFile; - if (cfg.knowledge.enabled && agentsCfg.enabled) { - const filePath = `${projectPath}/${agentsCfg.path}`; + if (isValidProjectPath(projectPath) && cfg.knowledge.enabled && agentsCfg.enabled) { + const filePath = join(projectPath, agentsCfg.path); exportToFile({ projectPath, filePath }); } } catch (e) { diff --git a/test/index.test.ts b/test/index.test.ts index 4f19677..3bfc596 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach } from "bun:test"; -import { isContextOverflow, buildRecoveryMessage, LorePlugin } from "../src/index"; +import { isContextOverflow, buildRecoveryMessage, LorePlugin, isValidProjectPath } from "../src/index"; import * as ltm from "../src/ltm"; import type { Plugin } from "@opencode-ai/plugin"; @@ -622,3 +622,45 @@ describe("LTM session cache", () => { } }); }); + +// ── isValidProjectPath tests ───────────────────────────────────────── + +describe("isValidProjectPath", () => { + test("returns false for root path '/'", () => { + expect(isValidProjectPath("/")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(isValidProjectPath("")).toBe(false); + }); + + test("returns true for a normal project path", () => { + expect(isValidProjectPath("/home/user/project")).toBe(true); + }); + + test("returns true for a relative path", () => { + expect(isValidProjectPath("./my-project")).toBe(true); + }); +}); + +// ── Plugin with invalid project path ───────────────────────────────── + +describe("LorePlugin — invalid project path", () => { + test("initializes without crashing when projectPath is '/'", async () => { + const { client } = createMockClient(); + + // Simulate launching outside a git repo: no worktree, directory is "/" + const hooks = await LorePlugin({ + client, + project: { id: "test", path: "/" } as any, + directory: "/", + worktree: "", + serverUrl: new URL("http://localhost:0"), + $: {} as any, + }); + + // Plugin should return hooks without crashing + expect(hooks).toBeTruthy(); + expect(hooks.event).toBeDefined(); + }); +});