Merge pull request #1952 from gustavosmendes/codex/fix-write-existing-file-guard-1871

fix: make write-existing-file-guard read-gated with overwrite bypass
This commit is contained in:
YeonGyu-Kim
2026-02-21 02:17:11 +09:00
committed by GitHub
4 changed files with 793 additions and 282 deletions

View File

@@ -1,50 +1,262 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync } from "fs"
import { resolve, isAbsolute, join, normalize, sep } from "path"
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path"
import { log } from "../../shared"
type GuardArgs = {
filePath?: string
path?: string
file_path?: string
overwrite?: boolean | string
}
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined
}
return value as Record<string, unknown>
}
function getPathFromArgs(args: GuardArgs | undefined): string | undefined {
return args?.filePath ?? args?.path ?? args?.file_path
}
function resolveInputPath(ctx: PluginInput, inputPath: string): string {
return normalize(isAbsolute(inputPath) ? inputPath : resolve(ctx.directory, inputPath))
}
function isPathInsideDirectory(pathToCheck: string, directory: string): boolean {
const relativePath = relative(directory, pathToCheck)
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
function toCanonicalPath(absolutePath: string): string {
let canonicalPath = absolutePath
if (existsSync(absolutePath)) {
try {
canonicalPath = realpathSync.native(absolutePath)
} catch {
canonicalPath = absolutePath
}
} else {
const absoluteDir = dirname(absolutePath)
const resolvedDir = existsSync(absoluteDir) ? realpathSync.native(absoluteDir) : absoluteDir
canonicalPath = join(resolvedDir, basename(absolutePath))
}
// Preserve canonical casing from the filesystem to avoid collapsing distinct
// files on case-sensitive volumes (supported on all major OSes).
return normalize(canonicalPath)
}
function isOverwriteEnabled(value: boolean | string | undefined): boolean {
if (value === true) {
return true
}
if (typeof value === "string") {
return value.toLowerCase() === "true"
}
return false
}
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
return {
"tool.execute.before": async (input, output) => {
const toolName = input.tool?.toLowerCase()
if (toolName !== "write") {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep
const touchSession = (sessionID: string): void => {
sessionLastAccess.set(sessionID, Date.now())
}
const evictLeastRecentlyUsedSession = (): void => {
let oldestSessionID: string | undefined
let oldestSeen = Number.POSITIVE_INFINITY
for (const [sessionID, lastSeen] of sessionLastAccess.entries()) {
if (lastSeen < oldestSeen) {
oldestSeen = lastSeen
oldestSessionID = sessionID
}
}
if (!oldestSessionID) {
return
}
readPermissionsBySession.delete(oldestSessionID)
sessionLastAccess.delete(oldestSessionID)
}
const ensureSessionReadSet = (sessionID: string): Set<string> => {
let readSet = readPermissionsBySession.get(sessionID)
if (!readSet) {
if (readPermissionsBySession.size >= MAX_TRACKED_SESSIONS) {
evictLeastRecentlyUsedSession()
}
readSet = new Set<string>()
readPermissionsBySession.set(sessionID, readSet)
}
touchSession(sessionID)
return readSet
}
const trimSessionReadSet = (readSet: Set<string>): void => {
while (readSet.size > MAX_TRACKED_PATHS_PER_SESSION) {
const oldestPath = readSet.values().next().value
if (!oldestPath) {
return
}
const args = output.args as
| { filePath?: string; path?: string; file_path?: string }
| undefined
const filePath = args?.filePath ?? args?.path ?? args?.file_path
readSet.delete(oldestPath)
}
}
const registerReadPermission = (sessionID: string, canonicalPath: string): void => {
const readSet = ensureSessionReadSet(sessionID)
if (readSet.has(canonicalPath)) {
readSet.delete(canonicalPath)
}
readSet.add(canonicalPath)
trimSessionReadSet(readSet)
}
const consumeReadPermission = (sessionID: string, canonicalPath: string): boolean => {
const readSet = readPermissionsBySession.get(sessionID)
if (!readSet || !readSet.has(canonicalPath)) {
return false
}
readSet.delete(canonicalPath)
touchSession(sessionID)
return true
}
const invalidateOtherSessions = (canonicalPath: string, writingSessionID?: string): void => {
for (const [sessionID, readSet] of readPermissionsBySession.entries()) {
if (writingSessionID && sessionID === writingSessionID) {
continue
}
readSet.delete(canonicalPath)
}
}
return {
"tool.execute.before": async (input, output) => {
const toolName = input.tool?.toLowerCase()
if (toolName !== "write" && toolName !== "read") {
return
}
const argsRecord = asRecord(output.args)
const args = argsRecord as GuardArgs | undefined
const filePath = getPathFromArgs(args)
if (!filePath) {
return
}
const resolvedPath = normalize(
isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)
)
const resolvedPath = resolveInputPath(ctx, filePath)
const canonicalPath = toCanonicalPath(resolvedPath)
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
if (existsSync(resolvedPath)) {
const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep
const isSisyphusMarkdown =
resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md")
if (isSisyphusMarkdown) {
log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", {
sessionID: input.sessionID,
filePath,
})
if (!isInsideSessionDirectory) {
if (toolName === "read") {
return
}
log("[write-existing-file-guard] Blocking write to existing file", {
log("[write-existing-file-guard] Blocking write outside session directory", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
throw new Error("File already exists. Use edit tool instead.")
throw new Error(OUTSIDE_SESSION_MESSAGE)
}
if (toolName === "read") {
if (!existsSync(resolvedPath) || !input.sessionID) {
return
}
registerReadPermission(input.sessionID, canonicalPath)
return
}
const overwriteEnabled = isOverwriteEnabled(args?.overwrite)
if (argsRecord && "overwrite" in argsRecord) {
// Intentionally mutate output args so overwrite bypass remains hook-only.
delete argsRecord.overwrite
}
if (!existsSync(resolvedPath)) {
return
}
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
if (isSisyphusPath) {
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
sessionID: input.sessionID,
filePath,
})
invalidateOtherSessions(canonicalPath, input.sessionID)
return
}
if (overwriteEnabled) {
log("[write-existing-file-guard] Allowing overwrite flag bypass", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
invalidateOtherSessions(canonicalPath, input.sessionID)
return
}
if (input.sessionID && consumeReadPermission(input.sessionID, canonicalPath)) {
log("[write-existing-file-guard] Allowing overwrite after read", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
invalidateOtherSessions(canonicalPath, input.sessionID)
return
}
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.")
},
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.deleted") {
return
}
const props = event.properties as { info?: { id?: string } } | undefined
const sessionID = props?.info?.id
if (!sessionID) {
return
}
readPermissionsBySession.delete(sessionID)
sessionLastAccess.delete(sessionID)
},
}
}

View File

@@ -1,305 +1,551 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
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 { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
import { createWriteExistingFileGuardHook } from "./index"
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
type Hook = ReturnType<typeof createWriteExistingFileGuardHook>
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: string
let ctx: { directory: string }
let hook: ReturnType<typeof 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<string, unknown>
}): Promise<{ args: Record<string, unknown> }> => {
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<void> => {
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
}
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-guard-test-"))
ctx = { directory: tempDir }
hook = createWriteExistingFileGuardHook(ctx as any)
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-"))
hook = createWriteExistingFileGuardHook({ directory: tempDir } as never)
callCounter = 0
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
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" } }
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()
})
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
test("#given existing file without read or overwrite #when write executes #then blocks", async () => {
const existingFile = createFile("existing.txt")
//#then
await expect(result).resolves.toBeUndefined()
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 },
})
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" } }
await expect(
invoke({
tool: "write",
sessionID,
outputArgs: { filePath: existingFile, content: "first overwrite" },
})
).resolves.toBeDefined()
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
await expect(
invoke({
tool: "write",
sessionID,
outputArgs: { filePath: existingFile, content: "second overwrite" },
})
).rejects.toThrow(BLOCK_MESSAGE)
})
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
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 },
})
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" } }
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" },
}),
])
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
const successCount = results.filter((result) => result.status === "fulfilled").length
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected"
)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
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 },
})
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" } }
await expect(
invoke({
tool: "write",
sessionID: "ses_writer",
outputArgs: { filePath: existingFile, content: "new content" },
})
).rejects.toThrow(BLOCK_MESSAGE)
})
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
test("#given overwrite true boolean #when write executes #then bypasses guard and strips overwrite", async () => {
const existingFile = createFile("overwrite-boolean.txt")
//#then
await expect(result).resolves.toBeUndefined()
const output = await invoke({
tool: "write",
outputArgs: {
filePath: existingFile,
content: "new content",
overwrite: true,
},
})
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" } }
expect(output.args.overwrite).toBeUndefined()
})
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
test("#given overwrite true string #when write executes #then bypasses guard and strips overwrite", async () => {
const existingFile = createFile("overwrite-string.txt")
//#then
await expect(result).resolves.toBeUndefined()
const output = await invoke({
tool: "write",
outputArgs: {
filePath: existingFile,
content: "new content",
overwrite: "true",
},
})
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" } }
expect(output.args.overwrite).toBeUndefined()
})
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
test("#given overwrite falsy values #when write executes #then does not bypass guard", async () => {
const existingFile = createFile("overwrite-falsy.txt")
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
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("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" } }
test("#given two sessions read same file #when one writes #then other session is invalidated", async () => {
const existingFile = createFile("invalidate.txt")
//#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()
})
await invoke({
tool: "read",
sessionID: "ses_a",
outputArgs: { filePath: existingFile },
})
await invoke({
tool: "read",
sessionID: "ses_b",
outputArgs: { filePath: existingFile },
})
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" } }
await expect(
invoke({
tool: "write",
sessionID: "ses_b",
outputArgs: { filePath: existingFile, content: "updated by B" },
})
).resolves.toBeDefined()
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
await expect(
invoke({
tool: "write",
sessionID: "ses_a",
outputArgs: { filePath: existingFile, content: "updated by A" },
})
).rejects.toThrow(BLOCK_MESSAGE)
})
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
test("#given existing file under .sisyphus #when write executes #then always allows", async () => {
const existingFile = createFile(".sisyphus/plans/plan.txt")
await expect(
invoke({
tool: "write",
outputArgs: { filePath: existingFile, content: "new plan" },
})
).resolves.toBeDefined()
})
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 },
})
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" } }
await expect(
invoke({
tool: "write",
sessionID,
outputArgs: { [variant]: existingFile, content: `overwrite via ${variant}` },
})
).resolves.toBeDefined()
}
})
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
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()
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.")
await expect(
invoke({
tool: "read",
outputArgs: {},
})
).resolves.toBeDefined()
})
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" } }
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"
//#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.")
})
describe(".sisyphus/*.md exception", () => {
test("allows write to existing .sisyphus/plans/plan.md", async () => {
//#given
const sisyphusDir = path.join(tempDir, ".sisyphus", "plans")
fs.mkdirSync(sisyphusDir, { recursive: true })
const planFile = path.join(sisyphusDir, "plan.md")
fs.writeFileSync(planFile, "# Existing Plan")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: planFile, content: "# Updated Plan" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("allows write to existing .sisyphus/notes.md", async () => {
//#given
const sisyphusDir = path.join(tempDir, ".sisyphus")
fs.mkdirSync(sisyphusDir, { recursive: true })
const notesFile = path.join(sisyphusDir, "notes.md")
fs.writeFileSync(notesFile, "# Notes")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: notesFile, content: "# Updated Notes" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("allows write to existing .sisyphus/*.md using relative path", async () => {
//#given
const sisyphusDir = path.join(tempDir, ".sisyphus")
fs.mkdirSync(sisyphusDir, { recursive: true })
const planFile = path.join(sisyphusDir, "plan.md")
fs.writeFileSync(planFile, "# Plan")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: ".sisyphus/plan.md", content: "# Updated" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("blocks write to existing .sisyphus/file.txt (non-markdown)", async () => {
//#given
const sisyphusDir = path.join(tempDir, ".sisyphus")
fs.mkdirSync(sisyphusDir, { recursive: true })
const textFile = path.join(sisyphusDir, "file.txt")
fs.writeFileSync(textFile, "content")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: textFile, 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 when .sisyphus is in parent path but not under ctx.directory", async () => {
//#given
const fakeSisyphusParent = path.join(os.tmpdir(), ".sisyphus", "evil-project")
fs.mkdirSync(fakeSisyphusParent, { recursive: true })
const evilFile = path.join(fakeSisyphusParent, "plan.md")
fs.writeFileSync(evilFile, "# Evil Plan")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: evilFile, content: "# Hacked" } }
//#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.")
// cleanup
fs.rmSync(path.join(os.tmpdir(), ".sisyphus"), { recursive: true, force: true })
})
test("blocks write to existing regular file (not in .sisyphus)", async () => {
//#given
const regularFile = path.join(tempDir, "regular.md")
fs.writeFileSync(regularFile, "# Regular")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: regularFile, content: "# Updated" } }
//#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.")
})
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 blocks", 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: "attempted overwrite" },
})
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
} 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) {
console.warn(
"Skipping symlink test: symlinks are not supported or cannot be created in this environment.",
error
)
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 oldestFile = createFile("path-cap/0.txt")
let newestFile = oldestFile
await invoke({
tool: "read",
sessionID,
outputArgs: { filePath: oldestFile },
})
for (let index = 1; index <= MAX_TRACKED_PATHS_PER_SESSION; 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()
// delete the session to trigger cleanup of any stored permissions/state
await invoke({
tool: "session.deleted",
sessionID,
outputArgs: {},
})
// 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)
})
})

View File

@@ -383,3 +383,55 @@ type EventInput = { event: { type: string; properties?: Record<string, unknown>
expect(dispatchCalls[1].event.type).toBe("session.idle")
})
})
describe("createEventHandler - event forwarding", () => {
it("forwards session.deleted to write-existing-file-guard hook", async () => {
//#given
const forwardedEvents: EventInput[] = []
const disconnectedSessions: string[] = []
const deletedSessions: string[] = []
const eventHandler = createEventHandler({
ctx: {} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
skillMcpManager: {
disconnectSession: async (sessionID: string) => {
disconnectedSessions.push(sessionID)
},
},
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async ({ sessionID }: { sessionID: string }) => {
deletedSessions.push(sessionID)
},
},
} as never,
hooks: {
writeExistingFileGuard: {
event: async (input: EventInput) => {
forwardedEvents.push(input)
},
},
} as never,
})
const sessionID = "ses_forward_delete_event"
//#when
await eventHandler({
event: {
type: "session.deleted",
properties: { info: { id: sessionID } },
},
})
//#then
expect(forwardedEvents.length).toBe(1)
expect(forwardedEvents[0]?.event.type).toBe("session.deleted")
expect(disconnectedSessions).toEqual([sessionID])
expect(deletedSessions).toEqual([sessionID])
})
})

View File

@@ -48,6 +48,7 @@ export function createEventHandler(args: {
await Promise.resolve(hooks.ralphLoop?.event?.(input))
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))
await Promise.resolve(hooks.atlasHook?.handler?.(input))
}