Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 206 additions & 0 deletions src/hooks/write-existing-file-guard/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions src/hooks/write-existing-file-guard/index.ts
Original file line number Diff line number Diff line change
@@ -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.")
}
},
}
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
createUnstableAgentBabysitterHook,
createPreemptiveCompactionHook,
createTasksTodowriteDisablerHook,
createWriteExistingFileGuardHook,
} from "./hooks";
import {
contextCollector,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down