From 847d9941999f424be47efb3321f0b447e926ec72 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 13:12:44 +0900 Subject: [PATCH 1/3] fix: allow Prometheus to overwrite .sisyphus/*.md plan files Add exception in write-existing-file-guard for .sisyphus/*.md files so Prometheus can rewrite plan files without being blocked by the guard. The prometheus-md-only hook (which runs later) still validates that only Prometheus can write to these paths, preserving security. Fixes #1576 --- .../write-existing-file-guard/index.test.ts | 82 ++++++++++++++++++- src/hooks/write-existing-file-guard/index.ts | 9 ++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts index 3fd2ddd4..f9654b64 100644 --- a/src/hooks/write-existing-file-guard/index.test.ts +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -198,9 +198,89 @@ 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 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..f3a52240 100644 --- a/src/hooks/write-existing-file-guard/index.ts +++ b/src/hooks/write-existing-file-guard/index.ts @@ -20,6 +20,15 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { const resolvedPath = isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath) if (existsSync(resolvedPath)) { + const isSisyphusMarkdown = resolvedPath.replace(/\\/g, "/").includes("/.sisyphus/") && filePath.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, From 38169523c4678cba553f3f6d271689cc0e5912c2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 18:49:16 +0900 Subject: [PATCH 2/3] fix: anchor .sisyphus path check to ctx.directory to prevent false positives - Uses path.join(ctx.directory, '.sisyphus') + sep as prefix instead of loose .includes() - Prevents false positive when .sisyphus exists in parent directories outside project root - Adds test for the false positive case (cubic review feedback) --- bun.lock | 28 +++++++++---------- .../write-existing-file-guard/index.test.ts | 19 +++++++++++++ src/hooks/write-existing-file-guard/index.ts | 5 ++-- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index b2412a61..c65491b0 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.2.3", - "oh-my-opencode-darwin-x64": "3.2.3", - "oh-my-opencode-linux-arm64": "3.2.3", - "oh-my-opencode-linux-arm64-musl": "3.2.3", - "oh-my-opencode-linux-x64": "3.2.3", - "oh-my-opencode-linux-x64-musl": "3.2.3", - "oh-my-opencode-windows-x64": "3.2.3", + "oh-my-opencode-darwin-arm64": "3.2.4", + "oh-my-opencode-darwin-x64": "3.2.4", + "oh-my-opencode-linux-arm64": "3.2.4", + "oh-my-opencode-linux-arm64-musl": "3.2.4", + "oh-my-opencode-linux-x64": "3.2.4", + "oh-my-opencode-linux-x64-musl": "3.2.4", + "oh-my-opencode-windows-x64": "3.2.4", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Doc9xQCj5Jmx3PzouBIfvDwmfWM94Y9Q9IngFqOjrVpfBef9V/WIH0PlhJU6ps4BKGey8Nf2afFq3UE06Z63Hg=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-6vG49R/nkbZYhAqN2oStA+8reZRo2KPPHSbhQd4htdEpzS4ipVz6pW/YTj/TDwunQO7hy66AhP9hOR4pJcoDeA=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-w7lO0Hn/AlLCHe33KPbje83Js2h5weDWVMuopEs6d3pi/1zkRDBEhCi63S4J0d0EKod9kEPQA6ojtdVJ4J39zQ=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Utfpclg8xHj93+faX2L4dpkzhM6D58YEtjkVlHq4CxZ8MdpYCs2l4NtY/b9T1GWmtQWFxZQhmIdAcwe1qApgpQ=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m1tS1jRLO2Svm5NuetK3BAgdAR8b2GkiIfMFoIYsLJTPmzIkXaigAYkFq+BXCs5JAbRmPmvjndz9cuCddnPADQ=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-z4Zlvt1a1PSQVprbgx6bLOeNuILX4d9p80GrTWuuYzqY+OEgbb74LVVUFCsvt8UgnhRTnHuhmphSpIL7UznzZg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Q/0AGtOuUFGNGIX8F6iD5W8c2spbjrqVBPt0B7laQSwnScKs/BI+TvM6HRE37vhoWg+fzhAX3QYJ2H9Un9FYrg=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pCCPM8rsuwMR3a7XIDyYyr/D1HkMPffOYGXeOY8vBaLL8NKFl8d0H5twA3HIiEqcDINHV3kw9zteL2paW+mHSQ=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RIAyoj2XbT8vH++5fPUkdO+D1tfqxh+iWto7CqWr1TgbABbBJljGk91HJgS9xjnxyCQJEpFhTmO7NMHKJcZOWQ=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vU9l4rS1oRpCgyXalBiUOOFPddIwSmuWoGY1PgO4dr6Db+gtEpmaDpLcEi5j4jFUDRLH6btQvNAp/eAydVgOJQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-nnQK3y7R4DrBvqdqRGbujL2oAAQnVVb23JHUbJPQ6YxrRRGWpLOVGvK5c16ykSFEUPl8eZDmi1ON/R4opKLOUw=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OZ+yRl7tOXoWTHh7zQ8WsTasKqZaIaVO3QeUQhDIS5JXFjbgjMgFeC/XBegsCgfqglWTOlMatmCO1S3nx2vy2w=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-mt8E/TkpaCp04pvzwntT8x8TaqXDt3zCD5X2eA8ZZMrb5ofNr5HyG5G4SFXrUh+Ez3b/3YXpNWv6f6rnAlk1Dg=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-W6TX8OiPCOmu7UZgZESh5DSWat0zH/6WPC3tdvjzwYnik9ZvRiyJGHh9B4uAG3DdqTC+pZJrpuTq1NctqMJiDA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts index f9654b64..ff5aba1f 100644 --- a/src/hooks/write-existing-file-guard/index.test.ts +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -267,6 +267,25 @@ describe("createWriteExistingFileGuardHook", () => { 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") diff --git a/src/hooks/write-existing-file-guard/index.ts b/src/hooks/write-existing-file-guard/index.ts index f3a52240..1256b241 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, sep } from "path" import { log } from "../../shared" export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { @@ -20,7 +20,8 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { const resolvedPath = isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath) if (existsSync(resolvedPath)) { - const isSisyphusMarkdown = resolvedPath.replace(/\\/g, "/").includes("/.sisyphus/") && filePath.endsWith(".md") + 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, From 9a8f03462f53f5a1303d887444d5f8cd2e8959f0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 19:01:28 +0900 Subject: [PATCH 3/3] fix: normalize resolvedPath before startsWith check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses cubic review feedback — resolvedPath may contain non-canonical segments when filePath is absolute, causing the startsWith check against sisyphusRoot to fail. --- src/hooks/write-existing-file-guard/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/write-existing-file-guard/index.ts b/src/hooks/write-existing-file-guard/index.ts index 1256b241..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, join, sep } from "path" +import { resolve, isAbsolute, join, normalize, sep } from "path" import { log } from "../../shared" export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { @@ -17,7 +17,7 @@ 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