import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" import { createWriteExistingFileGuardHook } from "./index" import { isOmoWorkspacePath } from "./tool-execute-before-handler" const BLOCK_MESSAGE = "File already exists. Use edit tool instead." type Hook = ReturnType function isCaseInsensitiveFilesystem(directory: string): boolean { const probeName = `CaseProbe_${Date.now()}_A.txt` const upperPath = join(directory, probeName) const lowerPath = join(directory, probeName.toLowerCase()) writeFileSync(upperPath, "probe") try { return existsSync(lowerPath) } finally { rmSync(upperPath, { force: true }) } } describe("createWriteExistingFileGuardHook", () => { let tempDir = "" let hook: Hook let callCounter = 0 const createFile = (relativePath: string, content = "existing content"): string => { const absolutePath = join(tempDir, relativePath) mkdirSync(dirname(absolutePath), { recursive: true }) writeFileSync(absolutePath, content) return absolutePath } const invoke = async (args: { tool: string sessionID?: string outputArgs: Record }): Promise<{ args: Record }> => { callCounter += 1 const output = { args: args.outputArgs } await hook["tool.execute.before"]?.( { tool: args.tool, sessionID: args.sessionID ?? "ses_default", callID: `call_${callCounter}`, } as never, output as never ) return output } const emitSessionDeleted = async (sessionID: string): Promise => { await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } } as never) } beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-")) hook = createWriteExistingFileGuardHook({ directory: tempDir } as never) callCounter = 0 }) afterEach(() => { rmSync(tempDir, { recursive: true, force: true }) }) test("#given non-existing file #when write executes #then allows", async () => { await expect( invoke({ tool: "write", outputArgs: { filePath: join(tempDir, "new-file.txt"), content: "new content" }, }) ).resolves.toBeDefined() }) test("#given existing file without read or overwrite #when write executes #then blocks", async () => { const existingFile = createFile("existing.txt") await expect( invoke({ tool: "write", outputArgs: { filePath: existingFile, content: "new content" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) test("#given same-session read #when write executes #then allows once and consumes permission", async () => { const existingFile = createFile("consume-once.txt") const sessionID = "ses_consume" await invoke({ tool: "read", sessionID, outputArgs: { filePath: existingFile }, }) await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "first overwrite" }, }) ).resolves.toBeDefined() await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "second overwrite" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) test("#given same-session concurrent writes #when only one read permission exists #then allows only one write", async () => { const existingFile = createFile("concurrent-consume.txt") const sessionID = "ses_concurrent" await invoke({ tool: "read", sessionID, outputArgs: { filePath: existingFile }, }) const results = await Promise.allSettled([ invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "first attempt" }, }), invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "second attempt" }, }), ]) const successCount = results.filter((result) => result.status === "fulfilled").length const failures = results.filter( (result): result is PromiseRejectedResult => result.status === "rejected" ) expect(successCount).toBe(1) expect(failures).toHaveLength(1) expect(String(failures[0]?.reason)).toContain(BLOCK_MESSAGE) }) test("#given read in another session #when write executes #then blocks", async () => { const existingFile = createFile("cross-session.txt") await invoke({ tool: "read", sessionID: "ses_reader", outputArgs: { filePath: existingFile }, }) await expect( invoke({ tool: "write", sessionID: "ses_writer", outputArgs: { filePath: existingFile, content: "new content" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) test("#given overwrite true boolean #when write executes #then bypasses guard and strips overwrite", async () => { const existingFile = createFile("overwrite-boolean.txt") const output = await invoke({ tool: "write", outputArgs: { filePath: existingFile, content: "new content", overwrite: true, }, }) expect(output.args.overwrite).toBeUndefined() }) test("#given overwrite true string #when write executes #then bypasses guard and strips overwrite", async () => { const existingFile = createFile("overwrite-string.txt") const output = await invoke({ tool: "write", outputArgs: { filePath: existingFile, content: "new content", overwrite: "true", }, }) expect(output.args.overwrite).toBeUndefined() }) test("#given overwrite falsy values #when write executes #then does not bypass guard", async () => { const existingFile = createFile("overwrite-falsy.txt") for (const overwrite of [false, "false"] as const) { await expect( invoke({ tool: "write", outputArgs: { filePath: existingFile, content: "new content", overwrite, }, }) ).rejects.toThrow(BLOCK_MESSAGE) } }) test("#given two sessions read same file #when one writes #then other session is invalidated", async () => { const existingFile = createFile("invalidate.txt") await invoke({ tool: "read", sessionID: "ses_a", outputArgs: { filePath: existingFile }, }) await invoke({ tool: "read", sessionID: "ses_b", outputArgs: { filePath: existingFile }, }) await expect( invoke({ tool: "write", sessionID: "ses_b", outputArgs: { filePath: existingFile, content: "updated by B" }, }) ).resolves.toBeDefined() await expect( invoke({ tool: "write", sessionID: "ses_a", outputArgs: { filePath: existingFile, content: "updated by A" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) test("#given existing file under .omo #when write executes #then always allows", async () => { const existingFile = createFile(".omo/plans/plan.txt") await expect( invoke({ tool: "write", outputArgs: { filePath: existingFile, content: "new plan" }, }) ).resolves.toBeDefined() }) test("#given canonical paths #when checking .omo workspace segment #then supports Windows separators", () => { expect(isOmoWorkspacePath(".omo/plans/plan.txt")).toBe(true) expect(isOmoWorkspacePath("/repo/.omo/plans/plan.txt")).toBe(true) expect(isOmoWorkspacePath(String.raw`C:\repo\.omo\plans\plan.txt`)).toBe(true) expect(isOmoWorkspacePath("/repo/work.omo/plans/plan.txt")).toBe(false) expect(isOmoWorkspacePath(String.raw`C:\repo\.omo-backup\plans\plan.txt`)).toBe(false) }) test("#given file arg variants #when read then write executes #then supports all variants", async () => { const existingFile = createFile("variants.txt") const variants: Array<"filePath" | "path" | "file_path"> = [ "filePath", "path", "file_path", ] for (const variant of variants) { const sessionID = `ses_${variant}` await invoke({ tool: "read", sessionID, outputArgs: { [variant]: existingFile }, }) await expect( invoke({ tool: "write", sessionID, outputArgs: { [variant]: existingFile, content: `overwrite via ${variant}` }, }) ).resolves.toBeDefined() } }) test("#given tools without file path arg #when write and read execute #then ignores safely", async () => { await expect( invoke({ tool: "write", outputArgs: { content: "no path" }, }) ).resolves.toBeDefined() await expect( invoke({ tool: "read", outputArgs: {}, }) ).resolves.toBeDefined() }) test("#given non-read-write tool #when it executes #then does not grant write permission", async () => { const existingFile = createFile("ignored-tool.txt") const sessionID = "ses_ignored_tool" await invoke({ tool: "edit", sessionID, outputArgs: { filePath: existingFile, oldString: "old", newString: "new" }, }) await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "should block" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) test("#given relative read and absolute write #when same session writes #then allows", async () => { createFile("relative-absolute.txt") const sessionID = "ses_relative_absolute" const relativePath = "relative-absolute.txt" const absolutePath = resolve(tempDir, relativePath) await invoke({ tool: "read", sessionID, outputArgs: { filePath: relativePath }, }) await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: absolutePath, content: "updated" }, }) ).resolves.toBeDefined() }) test("#given existing file outside session directory #when write executes #then allows", async () => { const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-")) try { const outsideFile = join(outsideDir, "outside.txt") writeFileSync(outsideFile, "outside") await expect( invoke({ tool: "write", outputArgs: { filePath: outsideFile, content: "allowed overwrite" }, }) ).resolves.toBeDefined() } finally { rmSync(outsideDir, { recursive: true, force: true }) } }) test("#given session read permission #when session deleted #then permission is cleaned up", async () => { const existingFile = createFile("session-cleanup.txt") const sessionID = "ses_cleanup" await invoke({ tool: "read", sessionID, outputArgs: { filePath: existingFile }, }) await emitSessionDeleted(sessionID) await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "after cleanup" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) test("#given case-different read path #when writing canonical path #then follows platform behavior", async () => { const canonicalFile = createFile("CaseFile.txt") const lowerCasePath = join(tempDir, "casefile.txt") const sessionID = "ses_case" const isCaseInsensitiveFs = isCaseInsensitiveFilesystem(tempDir) await invoke({ tool: "read", sessionID, outputArgs: { filePath: lowerCasePath }, }) const writeAttempt = invoke({ tool: "write", sessionID, outputArgs: { filePath: canonicalFile, content: "updated" }, }) if (isCaseInsensitiveFs) { await expect(writeAttempt).resolves.toBeDefined() return } await expect(writeAttempt).rejects.toThrow(BLOCK_MESSAGE) }) test("#given read via symlink #when write via real path #then allows overwrite", async () => { const targetFile = createFile("real/target.txt") const symlinkPath = join(tempDir, "linked-target.txt") const sessionID = "ses_symlink" try { symlinkSync(targetFile, symlinkPath) } catch (error) { // Symlinks not supported in this environment — skip return } await invoke({ tool: "read", sessionID, outputArgs: { filePath: symlinkPath }, }) await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: targetFile, content: "updated via symlink read" }, }) ).resolves.toBeDefined() }) test("#given session reads beyond path cap #when writing oldest and newest #then only newest is authorized", async () => { const sessionID = "ses_path_cap" const maxTrackedPathsPerSession = 4 hook = createWriteExistingFileGuardHook( { directory: tempDir } as never, { maxTrackedPathsPerSession }, ) const oldestFile = createFile("path-cap/0.txt") let newestFile = oldestFile await invoke({ tool: "read", sessionID, outputArgs: { filePath: oldestFile }, }) for (let index = 1; index <= maxTrackedPathsPerSession; index += 1) { newestFile = createFile(`path-cap/${index}.txt`) await invoke({ tool: "read", sessionID, outputArgs: { filePath: newestFile }, }) } await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: oldestFile, content: "stale write" }, }) ).rejects.toThrow(BLOCK_MESSAGE) await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: newestFile, content: "fresh write" }, }) ).resolves.toBeDefined() }) test("#given recently active session #when lru evicts #then keeps recent session permission", async () => { const existingFile = createFile("lru.txt") const hotSession = "ses_hot" await invoke({ tool: "read", sessionID: hotSession, outputArgs: { filePath: existingFile }, }) for (let index = 0; index < 255; index += 1) { await invoke({ tool: "read", sessionID: `ses_${index}`, outputArgs: { filePath: existingFile }, }) } await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)) await invoke({ tool: "read", sessionID: hotSession, outputArgs: { filePath: existingFile }, }) await invoke({ tool: "read", sessionID: "ses_overflow", outputArgs: { filePath: existingFile }, }) await expect( invoke({ tool: "write", sessionID: hotSession, outputArgs: { filePath: existingFile, content: "hot session write" }, }) ).resolves.toBeDefined() }) test("#given session permissions #when session deleted #then subsequent writes are blocked", async () => { const existingFile = createFile("cleanup.txt") const sessionID = "ses_cleanup" // establish permission by reading the existing file await invoke({ tool: "read", sessionID, outputArgs: { filePath: existingFile }, }) // sanity check: write should be allowed while the session is active await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "first write" }, }) ).resolves.toBeDefined() // read the file again to re-establish permission after first write consumed it await invoke({ tool: "read", sessionID, outputArgs: { filePath: existingFile }, }) // delete the session to trigger cleanup of any stored permissions/state await emitSessionDeleted(sessionID) // after session deletion, the previous permissions must no longer apply await expect( invoke({ tool: "write", sessionID, outputArgs: { filePath: existingFile, content: "second write after delete" }, }) ).rejects.toThrow(BLOCK_MESSAGE) }) })