fix: make write-existing-file-guard read-gated and test coverage
This commit is contained in:
parent
575fc383e0
commit
b6c433dae0
@ -1,50 +1,212 @@
|
|||||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import { existsSync } from "fs"
|
import { existsSync, realpathSync } from "fs"
|
||||||
import { resolve, isAbsolute, join, normalize, sep } from "path"
|
import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from "path"
|
||||||
|
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
|
||||||
|
type GuardArgs = {
|
||||||
|
filePath?: string
|
||||||
|
path?: string
|
||||||
|
file_path?: string
|
||||||
|
overwrite?: boolean | string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TRACKED_SESSIONS = 256
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
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 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 {
|
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||||
|
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 registerReadPermission = (sessionID: string, canonicalPath: string): void => {
|
||||||
|
const readSet = ensureSessionReadSet(sessionID)
|
||||||
|
readSet.add(canonicalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readSet.delete(canonicalPath)) {
|
||||||
|
touchSession(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tool.execute.before": async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
const toolName = input.tool?.toLowerCase()
|
const toolName = input.tool?.toLowerCase()
|
||||||
if (toolName !== "write") {
|
if (toolName !== "write" && toolName !== "read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = output.args as
|
const argsRecord = asRecord(output.args)
|
||||||
| { filePath?: string; path?: string; file_path?: string }
|
const args = argsRecord as GuardArgs | undefined
|
||||||
| undefined
|
const filePath = getPathFromArgs(args)
|
||||||
const filePath = args?.filePath ?? args?.path ?? args?.file_path
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = normalize(
|
const resolvedPath = resolveInputPath(ctx, filePath)
|
||||||
isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)
|
const canonicalPath = toCanonicalPath(resolvedPath)
|
||||||
)
|
|
||||||
|
|
||||||
if (existsSync(resolvedPath)) {
|
if (toolName === "read") {
|
||||||
const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep
|
if (!existsSync(resolvedPath) || !input.sessionID) {
|
||||||
const isSisyphusMarkdown =
|
|
||||||
resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md")
|
|
||||||
if (isSisyphusMarkdown) {
|
|
||||||
log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
filePath,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log("[write-existing-file-guard] Blocking write to existing file", {
|
registerReadPermission(input.sessionID, canonicalPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const overwriteEnabled = isOverwriteEnabled(args?.overwrite)
|
||||||
|
|
||||||
|
if (argsRecord && "overwrite" in argsRecord) {
|
||||||
|
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,
|
sessionID: input.sessionID,
|
||||||
filePath,
|
filePath,
|
||||||
resolvedPath,
|
resolvedPath,
|
||||||
})
|
})
|
||||||
|
invalidateOtherSessions(canonicalPath, input.sessionID)
|
||||||
throw new Error("File already exists. Use edit tool instead.")
|
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.")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,305 +1,343 @@
|
|||||||
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 { createWriteExistingFileGuardHook } from "./index"
|
import { createWriteExistingFileGuardHook } from "./index"
|
||||||
import * as fs from "fs"
|
|
||||||
import * as path from "path"
|
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||||
import * as os from "os"
|
|
||||||
|
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", () => {
|
describe("createWriteExistingFileGuardHook", () => {
|
||||||
let tempDir: string
|
let tempDir = ""
|
||||||
let ctx: { directory: string }
|
let hook: Hook
|
||||||
let hook: ReturnType<typeof createWriteExistingFileGuardHook>
|
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
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-guard-test-"))
|
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-"))
|
||||||
ctx = { directory: tempDir }
|
hook = createWriteExistingFileGuardHook({ directory: tempDir } as never)
|
||||||
hook = createWriteExistingFileGuardHook(ctx as any)
|
callCounter = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("tool.execute.before", () => {
|
test("#given non-existing file #when write executes #then allows", async () => {
|
||||||
test("allows write to non-existing file", async () => {
|
await expect(
|
||||||
//#given
|
invoke({
|
||||||
const nonExistingFile = path.join(tempDir, "new-file.txt")
|
tool: "write",
|
||||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
outputArgs: { filePath: join(tempDir, "new-file.txt"), content: "new content" },
|
||||||
const output = { args: { filePath: nonExistingFile, content: "hello" } }
|
})
|
||||||
|
).resolves.toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
//#when
|
test("#given existing file without read or overwrite #when write executes #then blocks", async () => {
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
const existingFile = createFile("existing.txt")
|
||||||
|
|
||||||
//#then
|
await expect(
|
||||||
await expect(result).resolves.toBeUndefined()
|
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 () => {
|
await expect(
|
||||||
//#given
|
invoke({
|
||||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
tool: "write",
|
||||||
fs.writeFileSync(existingFile, "existing content")
|
sessionID,
|
||||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
outputArgs: { filePath: existingFile, content: "first overwrite" },
|
||||||
const output = { args: { filePath: existingFile, content: "new content" } }
|
})
|
||||||
|
).resolves.toBeDefined()
|
||||||
|
|
||||||
//#when
|
await expect(
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
invoke({
|
||||||
|
tool: "write",
|
||||||
|
sessionID,
|
||||||
|
outputArgs: { filePath: existingFile, content: "second overwrite" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BLOCK_MESSAGE)
|
||||||
|
})
|
||||||
|
|
||||||
//#then
|
test("#given read in another session #when write executes #then blocks", async () => {
|
||||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
const existingFile = createFile("cross-session.txt")
|
||||||
|
|
||||||
|
await invoke({
|
||||||
|
tool: "read",
|
||||||
|
sessionID: "ses_reader",
|
||||||
|
outputArgs: { filePath: existingFile },
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocks write tool (lowercase) to existing file", async () => {
|
await expect(
|
||||||
//#given
|
invoke({
|
||||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
tool: "write",
|
||||||
fs.writeFileSync(existingFile, "existing content")
|
sessionID: "ses_writer",
|
||||||
const input = { tool: "write", sessionID: "ses_1", callID: "call_1" }
|
outputArgs: { filePath: existingFile, content: "new content" },
|
||||||
const output = { args: { filePath: existingFile, content: "new content" } }
|
})
|
||||||
|
).rejects.toThrow(BLOCK_MESSAGE)
|
||||||
|
})
|
||||||
|
|
||||||
//#when
|
test("#given overwrite true boolean #when write executes #then bypasses guard and strips overwrite", async () => {
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
const existingFile = createFile("overwrite-boolean.txt")
|
||||||
|
|
||||||
//#then
|
const output = await invoke({
|
||||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
tool: "write",
|
||||||
|
outputArgs: {
|
||||||
|
filePath: existingFile,
|
||||||
|
content: "new content",
|
||||||
|
overwrite: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores non-write tools", async () => {
|
expect(output.args.overwrite).toBeUndefined()
|
||||||
//#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
|
test("#given overwrite true string #when write executes #then bypasses guard and strips overwrite", async () => {
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
const existingFile = createFile("overwrite-string.txt")
|
||||||
|
|
||||||
//#then
|
const output = await invoke({
|
||||||
await expect(result).resolves.toBeUndefined()
|
tool: "write",
|
||||||
|
outputArgs: {
|
||||||
|
filePath: existingFile,
|
||||||
|
content: "new content",
|
||||||
|
overwrite: "true",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores tools without any file path arg", async () => {
|
expect(output.args.overwrite).toBeUndefined()
|
||||||
//#given
|
})
|
||||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
|
||||||
const output = { args: { command: "ls" } }
|
|
||||||
|
|
||||||
//#when
|
test("#given two sessions read same file #when one writes #then other session is invalidated", async () => {
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
const existingFile = createFile("invalidate.txt")
|
||||||
|
|
||||||
//#then
|
await invoke({
|
||||||
await expect(result).resolves.toBeUndefined()
|
tool: "read",
|
||||||
|
sessionID: "ses_a",
|
||||||
|
outputArgs: { filePath: existingFile },
|
||||||
|
})
|
||||||
|
await invoke({
|
||||||
|
tool: "read",
|
||||||
|
sessionID: "ses_b",
|
||||||
|
outputArgs: { filePath: existingFile },
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("alternative arg names", () => {
|
await expect(
|
||||||
test("blocks write using 'path' arg to existing file", async () => {
|
invoke({
|
||||||
//#given
|
tool: "write",
|
||||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
sessionID: "ses_b",
|
||||||
fs.writeFileSync(existingFile, "existing content")
|
outputArgs: { filePath: existingFile, content: "updated by B" },
|
||||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
})
|
||||||
const output = { args: { path: existingFile, content: "new content" } }
|
).resolves.toBeDefined()
|
||||||
|
|
||||||
//#when
|
await expect(
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
invoke({
|
||||||
|
tool: "write",
|
||||||
|
sessionID: "ses_a",
|
||||||
|
outputArgs: { filePath: existingFile, content: "updated by A" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BLOCK_MESSAGE)
|
||||||
|
})
|
||||||
|
|
||||||
//#then
|
test("#given existing file under .sisyphus #when write executes #then always allows", async () => {
|
||||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
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("blocks write using 'file_path' arg to existing file", async () => {
|
await expect(
|
||||||
//#given
|
invoke({
|
||||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
tool: "write",
|
||||||
fs.writeFileSync(existingFile, "existing content")
|
sessionID,
|
||||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
outputArgs: { [variant]: existingFile, content: `overwrite via ${variant}` },
|
||||||
const output = { args: { file_path: existingFile, content: "new content" } }
|
})
|
||||||
|
).resolves.toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
//#when
|
test("#given relative read and absolute write #when same session writes #then allows", async () => {
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
createFile("relative-absolute.txt")
|
||||||
|
const sessionID = "ses_relative_absolute"
|
||||||
|
const relativePath = "relative-absolute.txt"
|
||||||
|
const absolutePath = resolve(tempDir, relativePath)
|
||||||
|
|
||||||
//#then
|
await invoke({
|
||||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
tool: "read",
|
||||||
})
|
sessionID,
|
||||||
|
outputArgs: { filePath: relativePath },
|
||||||
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", () => {
|
await expect(
|
||||||
test("blocks write to existing file using relative path", async () => {
|
invoke({
|
||||||
//#given
|
tool: "write",
|
||||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
sessionID,
|
||||||
fs.writeFileSync(existingFile, "existing content")
|
outputArgs: { filePath: absolutePath, content: "updated" },
|
||||||
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.")
|
|
||||||
})
|
})
|
||||||
|
).resolves.toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("allows write to non-existing file using relative path", async () => {
|
test("#given case-different read path #when writing canonical path #then follows platform behavior", async () => {
|
||||||
//#given
|
const canonicalFile = createFile("CaseFile.txt")
|
||||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
const lowerCasePath = join(tempDir, "casefile.txt")
|
||||||
const output = { args: { filePath: "new-file.txt", content: "hello" } }
|
const sessionID = "ses_case"
|
||||||
|
const isCaseInsensitiveFs = isCaseInsensitiveFilesystem(tempDir)
|
||||||
|
|
||||||
//#when
|
await invoke({
|
||||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
tool: "read",
|
||||||
|
sessionID,
|
||||||
//#then
|
outputArgs: { filePath: lowerCasePath },
|
||||||
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.")
|
|
||||||
})
|
|
||||||
|
|
||||||
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.")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
})
|
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 {
|
||||||
|
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 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user