diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts index 3fd2ddd4..ff5aba1f 100644 --- a/src/hooks/write-existing-file-guard/index.test.ts +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -198,9 +198,108 @@ describe("createWriteExistingFileGuardHook", () => { //#when const result = differentHook["tool.execute.before"]?.(input as any, output as any) + //#then + await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + }) + + describe(".sisyphus/*.md exception", () => { + test("allows write to existing .sisyphus/plans/plan.md", async () => { + //#given + const sisyphusDir = path.join(tempDir, ".sisyphus", "plans") + fs.mkdirSync(sisyphusDir, { recursive: true }) + const planFile = path.join(sisyphusDir, "plan.md") + fs.writeFileSync(planFile, "# Existing Plan") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: planFile, content: "# Updated Plan" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("allows write to existing .sisyphus/notes.md", async () => { + //#given + const sisyphusDir = path.join(tempDir, ".sisyphus") + fs.mkdirSync(sisyphusDir, { recursive: true }) + const notesFile = path.join(sisyphusDir, "notes.md") + fs.writeFileSync(notesFile, "# Notes") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: notesFile, content: "# Updated Notes" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("allows write to existing .sisyphus/*.md using relative path", async () => { + //#given + const sisyphusDir = path.join(tempDir, ".sisyphus") + fs.mkdirSync(sisyphusDir, { recursive: true }) + const planFile = path.join(sisyphusDir, "plan.md") + fs.writeFileSync(planFile, "# Plan") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: ".sisyphus/plan.md", content: "# Updated" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("blocks write to existing .sisyphus/file.txt (non-markdown)", async () => { + //#given + const sisyphusDir = path.join(tempDir, ".sisyphus") + fs.mkdirSync(sisyphusDir, { recursive: true }) + const textFile = path.join(sisyphusDir, "file.txt") + fs.writeFileSync(textFile, "content") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: textFile, content: "new content" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + }) + + test("blocks write when .sisyphus is in parent path but not under ctx.directory", async () => { + //#given + const fakeSisyphusParent = path.join(os.tmpdir(), ".sisyphus", "evil-project") + fs.mkdirSync(fakeSisyphusParent, { recursive: true }) + const evilFile = path.join(fakeSisyphusParent, "plan.md") + fs.writeFileSync(evilFile, "# Evil Plan") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: evilFile, content: "# Hacked" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") + + // cleanup + fs.rmSync(path.join(os.tmpdir(), ".sisyphus"), { recursive: true, force: true }) + }) + + test("blocks write to existing regular file (not in .sisyphus)", async () => { + //#given + const regularFile = path.join(tempDir, "regular.md") + fs.writeFileSync(regularFile, "# Regular") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: regularFile, content: "# Updated" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + //#then await expect(result).rejects.toThrow("File already exists. Use edit tool instead.") }) }) - }) + }) +}) }) diff --git a/src/hooks/write-existing-file-guard/index.ts b/src/hooks/write-existing-file-guard/index.ts index 806cf1f4..1e8ca751 100644 --- a/src/hooks/write-existing-file-guard/index.ts +++ b/src/hooks/write-existing-file-guard/index.ts @@ -1,6 +1,6 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { existsSync } from "fs" -import { resolve, isAbsolute } from "path" +import { resolve, isAbsolute, join, normalize, sep } from "path" import { log } from "../../shared" export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { @@ -17,9 +17,19 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { return } - const resolvedPath = isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath) + const resolvedPath = normalize(isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)) if (existsSync(resolvedPath)) { + const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep + const isSisyphusMarkdown = resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md") + if (isSisyphusMarkdown) { + log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", { + sessionID: input.sessionID, + filePath, + }) + return + } + log("[write-existing-file-guard] Blocking write to existing file", { sessionID: input.sessionID, filePath,