diff --git a/src/hooks/prometheus-md-only/constants.ts b/src/hooks/prometheus-md-only/constants.ts new file mode 100644 index 00000000..062a7414 --- /dev/null +++ b/src/hooks/prometheus-md-only/constants.ts @@ -0,0 +1,9 @@ +export const HOOK_NAME = "prometheus-md-only" + +export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"] + +export const ALLOWED_EXTENSIONS = [".md"] + +export const ALLOWED_PATH_PREFIX = ".sisyphus/" + +export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit"] diff --git a/src/hooks/prometheus-md-only/index.test.ts b/src/hooks/prometheus-md-only/index.test.ts new file mode 100644 index 00000000..526cc3f6 --- /dev/null +++ b/src/hooks/prometheus-md-only/index.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test" +import { createPrometheusMdOnlyHook } from "./index" + +describe("prometheus-md-only", () => { + function createMockPluginInput() { + return { + client: {}, + directory: "/tmp/test", + } as any + } + + test("should block Prometheus from writing non-.md files", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: "test-session", + callID: "call-1", + agent: "Prometheus (Planner)", + } + const output = { + args: { filePath: "/path/to/file.ts" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).rejects.toThrow("can only write/edit .md files") + }) + + test("should allow Prometheus to write .md files inside .sisyphus/", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: "test-session", + callID: "call-1", + agent: "Prometheus (Planner)", + } + const output = { + args: { filePath: "/project/.sisyphus/plans/work-plan.md" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should block Prometheus from writing .md files outside .sisyphus/", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: "test-session", + callID: "call-1", + agent: "Prometheus (Planner)", + } + const output = { + args: { filePath: "/path/to/README.md" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).rejects.toThrow("can only write/edit .md files inside .sisyphus/") + }) + + test("should not affect non-Prometheus agents", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: "test-session", + callID: "call-1", + agent: "Sisyphus", + } + const output = { + args: { filePath: "/path/to/file.ts" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should not affect non-Write/Edit tools", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Read", + sessionID: "test-session", + callID: "call-1", + agent: "Prometheus (Planner)", + } + const output = { + args: { filePath: "/path/to/file.ts" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should block Edit tool for non-.md files", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Edit", + sessionID: "test-session", + callID: "call-1", + agent: "Prometheus (Planner)", + } + const output = { + args: { filePath: "/path/to/code.py" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).rejects.toThrow("can only write/edit .md files") + }) + + test("should handle missing filePath gracefully", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: "test-session", + callID: "call-1", + agent: "Prometheus (Planner)", + } + const output = { + args: {}, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should handle missing agent gracefully", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: "test-session", + callID: "call-1", + } + const output = { + args: { filePath: "/path/to/file.ts" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/prometheus-md-only/index.ts new file mode 100644 index 00000000..52e7c83c --- /dev/null +++ b/src/hooks/prometheus-md-only/index.ts @@ -0,0 +1,57 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS } from "./constants" +import { log } from "../../shared/logger" + +export * from "./constants" + +function isAllowedFile(filePath: string): boolean { + const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext => filePath.endsWith(ext)) + const isInAllowedPath = filePath.includes(ALLOWED_PATH_PREFIX) + return hasAllowedExtension && isInAllowedPath +} + +export function createPrometheusMdOnlyHook(_ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string; agent?: string }, + output: { args: Record; message?: string } + ): Promise => { + const agentName = input.agent + + if (!agentName || !PROMETHEUS_AGENTS.includes(agentName)) { + return + } + + const toolName = input.tool + if (!BLOCKED_TOOLS.includes(toolName)) { + return + } + + const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined + if (!filePath) { + return + } + + if (!isAllowedFile(filePath)) { + log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, { + sessionID: input.sessionID, + tool: toolName, + filePath, + agent: agentName, + }) + throw new Error( + `[${HOOK_NAME}] Prometheus (Planner) can only write/edit .md files inside .sisyphus/ directory. ` + + `Attempted to modify: ${filePath}. ` + + `Prometheus is a READ-ONLY planner. Use /start-work to execute the plan.` + ) + } + + log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, { + sessionID: input.sessionID, + tool: toolName, + filePath, + agent: agentName, + }) + }, + } +}