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",
|
"tasks-todowrite-disabler",
|
||||||
"runtime-fallback",
|
"runtime-fallback",
|
||||||
"write-existing-file-guard",
|
"write-existing-file-guard",
|
||||||
|
"notepad-write-guard",
|
||||||
"bash-file-read-guard",
|
"bash-file-read-guard",
|
||||||
"anthropic-effort",
|
"anthropic-effort",
|
||||||
"hashline-read-enhancer",
|
"hashline-read-enhancer",
|
||||||
|
|||||||
@@ -66,3 +66,4 @@ export { createTodoDescriptionOverrideHook } from "./todo-description-override"
|
|||||||
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
|
||||||
export { createLegacyPluginToastHook } from "./legacy-plugin-toast"
|
export { createLegacyPluginToastHook } from "./legacy-plugin-toast"
|
||||||
export { createFsyncSkipWarningHook } from "./fsync-skip-warning"
|
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,
|
createWebFetchRedirectGuardHook,
|
||||||
createTeamToolGating,
|
createTeamToolGating,
|
||||||
createFsyncSkipWarningHook,
|
createFsyncSkipWarningHook,
|
||||||
|
createNotepadWriteGuardHook,
|
||||||
} from "../../hooks"
|
} from "../../hooks"
|
||||||
import {
|
import {
|
||||||
getOpenCodeVersion,
|
getOpenCodeVersion,
|
||||||
@@ -45,6 +46,7 @@ export type ToolGuardHooks = {
|
|||||||
webfetchRedirectGuard: ReturnType<typeof createWebFetchRedirectGuardHook> | null
|
webfetchRedirectGuard: ReturnType<typeof createWebFetchRedirectGuardHook> | null
|
||||||
fsyncSkipWarning: ReturnType<typeof createFsyncSkipWarningHook> | null
|
fsyncSkipWarning: ReturnType<typeof createFsyncSkipWarningHook> | null
|
||||||
teamToolGating: ReturnType<typeof createTeamToolGating> | null
|
teamToolGating: ReturnType<typeof createTeamToolGating> | null
|
||||||
|
notepadWriteGuard: ReturnType<typeof createNotepadWriteGuardHook> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolGuardHooks(args: {
|
export function createToolGuardHooks(args: {
|
||||||
@@ -145,6 +147,10 @@ export function createToolGuardHooks(args: {
|
|||||||
? safeHook("fsync-skip-warning", () => createFsyncSkipWarningHook())
|
? safeHook("fsync-skip-warning", () => createFsyncSkipWarningHook())
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const notepadWriteGuard = isHookEnabled("notepad-write-guard")
|
||||||
|
? safeHook("notepad-write-guard", () => createNotepadWriteGuardHook())
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentChecker,
|
commentChecker,
|
||||||
toolOutputTruncator,
|
toolOutputTruncator,
|
||||||
@@ -162,5 +168,6 @@ export function createToolGuardHooks(args: {
|
|||||||
webfetchRedirectGuard,
|
webfetchRedirectGuard,
|
||||||
fsyncSkipWarning,
|
fsyncSkipWarning,
|
||||||
teamToolGating,
|
teamToolGating,
|
||||||
|
notepadWriteGuard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user