From 572c3c248e5860f486fc1770d0a77728ffc7bafe Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sat, 16 May 2026 06:52:43 -0400 Subject: [PATCH] fix(notepad-guard): refuse Write tool for .sisyphus/notepads files (#3685) Adds a new `notepad-write-guard` hook that intercepts Write tool calls whose target path matches `**/.sisyphus/notepads/**` and throws an actionable error instead of allowing the write to proceed. Without this guard, an agent that hits an Edit hash-mismatch failure could silently fall back to Write, destroying the entire history of an append-only notepad file (decisions.md, issues.md, etc.). The file carries an explicit "NEVER overwrite" warning that the agent ignores under context pressure. The guard is path-based so it works regardless of plan name or nesting depth. Non-notepad `.sisyphus/**` paths (e.g. plan files) are unaffected. The hook is wired into `create-tool-guard-hooks` under the hook name `notepad-write-guard` and follows the same safeCreateHook + HookName schema pattern as every other tool-guard hook. Co-Authored-By: Claude Sonnet 4.6 --- src/config/schema/hooks.ts | 1 + src/hooks/index.ts | 1 + src/hooks/notepad-write-guard/index.test.ts | 92 +++++++++++++++++++++ src/hooks/notepad-write-guard/index.ts | 37 +++++++++ src/plugin/hooks/create-tool-guard-hooks.ts | 7 ++ 5 files changed, 138 insertions(+) create mode 100644 src/hooks/notepad-write-guard/index.test.ts create mode 100644 src/hooks/notepad-write-guard/index.ts diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 641825da1..bdee70718 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -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", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 5ed94b813..3dc503077 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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" diff --git a/src/hooks/notepad-write-guard/index.test.ts b/src/hooks/notepad-write-guard/index.test.ts new file mode 100644 index 000000000..9b0e664a0 --- /dev/null +++ b/src/hooks/notepad-write-guard/index.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { createNotepadWriteGuardHook } from "./index" + +const REFUSED_PREFIX = "Refused: Write to" + +type Hook = ReturnType + +async function invoke( + hook: Hook, + args: { tool: string; filePath: string }, +): Promise { + 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") + }) +}) diff --git a/src/hooks/notepad-write-guard/index.ts b/src/hooks/notepad-write-guard/index.ts new file mode 100644 index 000000000..2302e27a3 --- /dev/null +++ b/src/hooks/notepad-write-guard/index.ts @@ -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 + 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 => { + 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.`, + ) + } + }, + } +} diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 7cd8ea166..d2f06146e 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -19,6 +19,7 @@ import { createWebFetchRedirectGuardHook, createTeamToolGating, createFsyncSkipWarningHook, + createNotepadWriteGuardHook, } from "../../hooks" import { getOpenCodeVersion, @@ -45,6 +46,7 @@ export type ToolGuardHooks = { webfetchRedirectGuard: ReturnType | null fsyncSkipWarning: ReturnType | null teamToolGating: ReturnType | null + notepadWriteGuard: ReturnType | 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, } }