Merge pull request #4082 from PeterPonyu/fix/3685-notepad-no-write-fallback
fix(notepad-guard): refuse Write tool for .sisyphus/notepads files (#3685)
This commit is contained in:
@@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([
|
||||
"tasks-todowrite-disabler",
|
||||
"runtime-fallback",
|
||||
"write-existing-file-guard",
|
||||
"notepad-write-guard",
|
||||
"bash-file-read-guard",
|
||||
"anthropic-effort",
|
||||
"hashline-read-enhancer",
|
||||
|
||||
@@ -66,3 +66,4 @@ export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
||||
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
||||
export { createLegacyPluginToastHook } from "./legacy-plugin-toast"
|
||||
export { createFsyncSkipWarningHook } from "./fsync-skip-warning"
|
||||
export { createNotepadWriteGuardHook } from "./notepad-write-guard"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createNotepadWriteGuardHook } from "./index"
|
||||
|
||||
const REFUSED_PREFIX = "Refused: Write to"
|
||||
|
||||
type Hook = ReturnType<typeof createNotepadWriteGuardHook>
|
||||
|
||||
async function invoke(
|
||||
hook: Hook,
|
||||
args: { tool: string; filePath: string },
|
||||
): Promise<void> {
|
||||
await hook["tool.execute.before"]?.(
|
||||
{ tool: args.tool } as never,
|
||||
{ args: { filePath: args.filePath } } as never,
|
||||
)
|
||||
}
|
||||
|
||||
describe("createNotepadWriteGuardHook", () => {
|
||||
test("#given notepad decisions.md #when write executes #then rejects with actionable error", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
await expect(
|
||||
invoke(hook, {
|
||||
tool: "write",
|
||||
filePath: ".sisyphus/notepads/foo/decisions.md",
|
||||
}),
|
||||
).rejects.toThrow(REFUSED_PREFIX)
|
||||
})
|
||||
|
||||
test("#given notepad state.json #when write executes #then rejects (entire notepad subtree blocked)", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
await expect(
|
||||
invoke(hook, {
|
||||
tool: "write",
|
||||
filePath: ".sisyphus/notepads/foo/state.json",
|
||||
}),
|
||||
).rejects.toThrow(REFUSED_PREFIX)
|
||||
})
|
||||
|
||||
test("#given regular src file #when write executes #then allows (not intercepted)", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
await expect(
|
||||
invoke(hook, {
|
||||
tool: "write",
|
||||
filePath: "src/index.ts",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given non-write tool on notepad path #when executes #then allows", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
await expect(
|
||||
invoke(hook, {
|
||||
tool: "read",
|
||||
filePath: ".sisyphus/notepads/foo/decisions.md",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given sisyphus plans file (not notepads) #when write executes #then allows", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
await expect(
|
||||
invoke(hook, {
|
||||
tool: "write",
|
||||
filePath: ".sisyphus/plans/my-plan.md",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given absolute notepad path #when write executes #then rejects", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
await expect(
|
||||
invoke(hook, {
|
||||
tool: "write",
|
||||
filePath: "/home/user/project/.sisyphus/notepads/plan/decisions.md",
|
||||
}),
|
||||
).rejects.toThrow(REFUSED_PREFIX)
|
||||
})
|
||||
|
||||
test("#given error message #when rejected #then message names the file and gives guidance", async () => {
|
||||
const hook = createNotepadWriteGuardHook()
|
||||
const filePath = ".sisyphus/notepads/foo/decisions.md"
|
||||
let caughtMessage = ""
|
||||
try {
|
||||
await invoke(hook, { tool: "write", filePath })
|
||||
} catch (err) {
|
||||
caughtMessage = String(err)
|
||||
}
|
||||
expect(caughtMessage).toContain(filePath)
|
||||
expect(caughtMessage).toContain("append-only")
|
||||
expect(caughtMessage).toContain("Report the original Edit failure")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { normalize } from "path"
|
||||
|
||||
const NOTEPAD_SEGMENT = `${normalize(".sisyphus/notepads")}/`
|
||||
|
||||
function isNotebookPath(filePath: string): boolean {
|
||||
const normalised = normalize(filePath)
|
||||
return normalised.includes(`/.sisyphus/notepads/`) || normalised.startsWith(NOTEPAD_SEGMENT)
|
||||
}
|
||||
|
||||
function resolveFilePath(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined
|
||||
const a = args as Record<string, unknown>
|
||||
const raw = a["filePath"] ?? a["path"] ?? a["file_path"]
|
||||
return typeof raw === "string" ? raw : undefined
|
||||
}
|
||||
|
||||
export function createNotepadWriteGuardHook(): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool?: string },
|
||||
_output: unknown,
|
||||
): Promise<void> => {
|
||||
if (input.tool?.toLowerCase() !== "write") return
|
||||
|
||||
const outputRecord = _output as { args?: unknown } | undefined
|
||||
const filePath = resolveFilePath(outputRecord?.args)
|
||||
if (!filePath) return
|
||||
|
||||
if (isNotebookPath(filePath)) {
|
||||
throw new Error(
|
||||
`Refused: Write to ${filePath} is blocked because notepad files are append-only and Write would destroy history. Report the original Edit failure to the user and ask for guidance instead.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createWebFetchRedirectGuardHook,
|
||||
createTeamToolGating,
|
||||
createFsyncSkipWarningHook,
|
||||
createNotepadWriteGuardHook,
|
||||
} from "../../hooks"
|
||||
import {
|
||||
getOpenCodeVersion,
|
||||
@@ -45,6 +46,7 @@ export type ToolGuardHooks = {
|
||||
webfetchRedirectGuard: ReturnType<typeof createWebFetchRedirectGuardHook> | null
|
||||
fsyncSkipWarning: ReturnType<typeof createFsyncSkipWarningHook> | null
|
||||
teamToolGating: ReturnType<typeof createTeamToolGating> | null
|
||||
notepadWriteGuard: ReturnType<typeof createNotepadWriteGuardHook> | null
|
||||
}
|
||||
|
||||
export function createToolGuardHooks(args: {
|
||||
@@ -145,6 +147,10 @@ export function createToolGuardHooks(args: {
|
||||
? safeHook("fsync-skip-warning", () => createFsyncSkipWarningHook())
|
||||
: null
|
||||
|
||||
const notepadWriteGuard = isHookEnabled("notepad-write-guard")
|
||||
? safeHook("notepad-write-guard", () => createNotepadWriteGuardHook())
|
||||
: null
|
||||
|
||||
return {
|
||||
commentChecker,
|
||||
toolOutputTruncator,
|
||||
@@ -162,5 +168,6 @@ export function createToolGuardHooks(args: {
|
||||
webfetchRedirectGuard,
|
||||
fsyncSkipWarning,
|
||||
teamToolGating,
|
||||
notepadWriteGuard,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user