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:
@@ -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([
|
||||
|
||||
@@ -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";
|
||||
|
||||
206
src/hooks/write-existing-file-guard/index.test.ts
Normal file
206
src/hooks/write-existing-file-guard/index.test.ts
Normal 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.")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
33
src/hooks/write-existing-file-guard/index.ts
Normal file
33
src/hooks/write-existing-file-guard/index.ts
Normal 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.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user