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:
YeonGyu-Kim
2026-05-21 00:37:27 +09:00
committed by GitHub
5 changed files with 138 additions and 0 deletions
+1
View File
@@ -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",
+1
View File
@@ -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")
})
})
+37
View File
@@ -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,
}
}