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
This commit is contained in:
YeonGyu-Kim
2026-02-04 16:05:00 +09:00
parent 8049ceb947
commit ddf878e53c
5 changed files with 246 additions and 0 deletions

View File

@@ -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([

View File

@@ -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";

View File

@@ -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<typeof createWriteExistingFileGuardHook>
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.")
})
})
})
})

View File

@@ -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.")
}
},
}
}

View File

@@ -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);