From ddf878e53cfc3ca5db1617bf3914110dc0345e41 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 4 Feb 2026 16:05:00 +0900 Subject: [PATCH] feat(write-existing-file-guard): add hook to prevent write tool from overwriting existing files Adds a PreToolUse hook that intercepts write operations and throws an error if the target file already exists, guiding users to use the edit tool instead. - Throws error: 'File already exists. Use edit tool instead.' - Hook is enabled by default, can be disabled via disabled_hooks - Includes comprehensive test suite with BDD-style comments --- src/config/schema.ts | 1 + src/hooks/index.ts | 1 + .../write-existing-file-guard/index.test.ts | 206 ++++++++++++++++++ src/hooks/write-existing-file-guard/index.ts | 33 +++ src/index.ts | 5 + 5 files changed, 246 insertions(+) create mode 100644 src/hooks/write-existing-file-guard/index.test.ts create mode 100644 src/hooks/write-existing-file-guard/index.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index b7441055..d406ee71 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -94,6 +94,7 @@ export const HookNameSchema = z.enum([ "unstable-agent-babysitter", "stop-continuation-guard", "tasks-todowrite-disabler", + "write-existing-file-guard", ]) export const BuiltinCommandNameSchema = z.enum([ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 407e7e46..bffb447e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -38,3 +38,4 @@ export { createCompactionContextInjector, type SummarizeContext } from "./compac export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter"; export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; +export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts new file mode 100644 index 00000000..3fd2ddd4 --- /dev/null +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -0,0 +1,206 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { createWriteExistingFileGuardHook } from "./index" +import * as fs from "fs" +import * as path from "path" +import * as os from "os" + +describe("createWriteExistingFileGuardHook", () => { + let tempDir: string + let ctx: { directory: string } + let hook: ReturnType + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-guard-test-")) + ctx = { directory: tempDir } + hook = createWriteExistingFileGuardHook(ctx as any) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + describe("tool.execute.before", () => { + test("allows write to non-existing file", async () => { + //#given + const nonExistingFile = path.join(tempDir, "new-file.txt") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: nonExistingFile, content: "hello" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("blocks write to existing file", async () => { + //#given + const existingFile = path.join(tempDir, "existing-file.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: existingFile, 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 tool (lowercase) to existing file", async () => { + //#given + const existingFile = path.join(tempDir, "existing-file.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: existingFile, 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("ignores non-write tools", async () => { + //#given + const existingFile = path.join(tempDir, "existing-file.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "Edit", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: existingFile, content: "new content" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("ignores tools without any file path arg", async () => { + //#given + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { command: "ls" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + describe("alternative arg names", () => { + test("blocks write using 'path' arg to existing file", async () => { + //#given + const existingFile = path.join(tempDir, "existing-file.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { path: existingFile, 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 using 'file_path' arg to existing file", async () => { + //#given + const existingFile = path.join(tempDir, "existing-file.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { file_path: existingFile, 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("allows write using 'path' arg to non-existing file", async () => { + //#given + const nonExistingFile = path.join(tempDir, "new-file.txt") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { path: nonExistingFile, content: "hello" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("allows write using 'file_path' arg to non-existing file", async () => { + //#given + const nonExistingFile = path.join(tempDir, "new-file.txt") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { file_path: nonExistingFile, content: "hello" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + }) + + describe("relative path resolution using ctx.directory", () => { + test("blocks write to existing file using relative path", async () => { + //#given + const existingFile = path.join(tempDir, "existing-file.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: "existing-file.txt", 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("allows write to non-existing file using relative path", async () => { + //#given + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: "new-file.txt", content: "hello" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("blocks write to nested relative path when file exists", async () => { + //#given + const subDir = path.join(tempDir, "subdir") + fs.mkdirSync(subDir) + const existingFile = path.join(subDir, "existing.txt") + fs.writeFileSync(existingFile, "existing content") + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: "subdir/existing.txt", 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("uses ctx.directory not process.cwd for relative path resolution", async () => { + //#given + const existingFile = path.join(tempDir, "test-file.txt") + fs.writeFileSync(existingFile, "content") + const differentCtx = { directory: tempDir } + const differentHook = createWriteExistingFileGuardHook(differentCtx as any) + const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" } + const output = { args: { filePath: "test-file.txt", content: "new" } } + + //#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.") + }) + }) + }) +}) diff --git a/src/hooks/write-existing-file-guard/index.ts b/src/hooks/write-existing-file-guard/index.ts new file mode 100644 index 00000000..806cf1f4 --- /dev/null +++ b/src/hooks/write-existing-file-guard/index.ts @@ -0,0 +1,33 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { existsSync } from "fs" +import { resolve, isAbsolute } from "path" +import { log } from "../../shared" + +export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { + return { + "tool.execute.before": async (input, output) => { + const toolName = input.tool?.toLowerCase() + if (toolName !== "write") { + return + } + + const args = output.args as { filePath?: string; path?: string; file_path?: string } | undefined + const filePath = args?.filePath ?? args?.path ?? args?.file_path + if (!filePath) { + return + } + + const resolvedPath = isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath) + + if (existsSync(resolvedPath)) { + log("[write-existing-file-guard] Blocking write to existing file", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + + throw new Error("File already exists. Use edit tool instead.") + } + }, + } +} diff --git a/src/index.ts b/src/index.ts index 7a67349d..baf4f959 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { createUnstableAgentBabysitterHook, createPreemptiveCompactionHook, createTasksTodowriteDisablerHook, + createWriteExistingFileGuardHook, } from "./hooks"; import { contextCollector, @@ -280,6 +281,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const questionLabelTruncator = createQuestionLabelTruncatorHook(); const subagentQuestionBlocker = createSubagentQuestionBlockerHook(); + const writeExistingFileGuard = isHookEnabled("write-existing-file-guard") + ? createWriteExistingFileGuardHook(ctx) + : null; const taskResumeInfo = createTaskResumeInfoHook(); @@ -720,6 +724,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { "tool.execute.before": async (input, output) => { await subagentQuestionBlocker["tool.execute.before"]?.(input, output); + await writeExistingFileGuard?.["tool.execute.before"]?.(input, output); await questionLabelTruncator["tool.execute.before"]?.(input, output); await claudeCodeHooks["tool.execute.before"](input, output); await nonInteractiveEnv?.["tool.execute.before"](input, output);