diff --git a/src/hooks/prometheus-md-only/constants.ts b/src/hooks/prometheus-md-only/constants.ts index b25db57f4..0c24b0498 100644 --- a/src/hooks/prometheus-md-only/constants.ts +++ b/src/hooks/prometheus-md-only/constants.ts @@ -4,7 +4,7 @@ export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"] export const ALLOWED_EXTENSIONS = [".md"] -export const ALLOWED_PATH_PREFIX = ".sisyphus/" +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 index 28ae3261b..ac0c93c96 100644 --- a/src/hooks/prometheus-md-only/index.test.ts +++ b/src/hooks/prometheus-md-only/index.test.ts @@ -70,7 +70,7 @@ describe("prometheus-md-only", () => { callID: "call-1", } const output = { - args: { filePath: "/project/.sisyphus/plans/work-plan.md" }, + args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" }, } // #when / #then @@ -295,4 +295,136 @@ describe("prometheus-md-only", () => { ).resolves.toBeUndefined() }) }) + + describe("cross-platform path validation", () => { + beforeEach(() => { + setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)") + }) + + test("should allow Windows-style backslash paths under .sisyphus/", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: ".sisyphus\\plans\\work-plan.md" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should allow mixed separator paths under .sisyphus/", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: ".sisyphus\\plans/work-plan.MD" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should allow uppercase .MD extension", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: ".sisyphus/plans/work-plan.MD" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should block paths outside workspace root even if containing .sisyphus", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: "/other/project/.sisyphus/plans/x.md" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).rejects.toThrow("can only write/edit .md files inside .sisyphus/") + }) + + test("should block nested .sisyphus directories", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: "src/.sisyphus/plans/x.md" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).rejects.toThrow("can only write/edit .md files inside .sisyphus/") + }) + + test("should block path traversal attempts", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: ".sisyphus/../secrets.md" }, + } + + // #when / #then + await expect( + hook["tool.execute.before"](input, output) + ).rejects.toThrow("can only write/edit .md files inside .sisyphus/") + }) + + test("should allow case-insensitive .SISYPHUS directory", async () => { + // #given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output = { + args: { filePath: ".SISYPHUS/plans/work-plan.md" }, + } + + // #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 index b0d9c45cc..5a0d7f990 100644 --- a/src/hooks/prometheus-md-only/index.ts +++ b/src/hooks/prometheus-md-only/index.ts @@ -1,16 +1,48 @@ import type { PluginInput } from "@opencode-ai/plugin" import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { join, resolve, relative, isAbsolute } from "node:path" import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants" import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" 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 +/** + * Cross-platform path validator for Prometheus file writes. + * Uses path.resolve/relative instead of string matching to handle: + * - Windows backslashes (e.g., .sisyphus\\plans\\x.md) + * - Mixed separators (e.g., .sisyphus\\plans/x.md) + * - Case-insensitive directory/extension matching + * - Workspace confinement (blocks paths outside root or via traversal) + */ +function isAllowedFile(filePath: string, workspaceRoot: string): boolean { + // 1. Resolve to absolute path + const resolved = resolve(workspaceRoot, filePath) + + // 2. Get relative path from workspace root + const rel = relative(workspaceRoot, resolved) + + // 3. Reject if escapes root (starts with ".." or is absolute) + if (rel.startsWith("..") || isAbsolute(rel)) { + return false + } + + // 4. Split by both separators and check first segment matches ALLOWED_PATH_PREFIX (case-insensitive) + // Guard: if rel is empty (filePath === workspaceRoot), segments[0] would be "" — reject + const segments = rel.split(/[/\\]/) + if (!segments[0] || segments[0].toLowerCase() !== ALLOWED_PATH_PREFIX.toLowerCase()) { + return false + } + + // 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive) + const hasAllowedExtension = ALLOWED_EXTENSIONS.some( + ext => resolved.toLowerCase().endsWith(ext.toLowerCase()) + ) + if (!hasAllowedExtension) { + return false + } + + return true } function getMessageDir(sessionID: string): string | null { @@ -35,7 +67,7 @@ function getAgentFromSession(sessionID: string): string | undefined { return findNearestMessageWithFields(messageDir)?.agent } -export function createPrometheusMdOnlyHook(_ctx: PluginInput) { +export function createPrometheusMdOnlyHook(ctx: PluginInput) { return { "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string }, @@ -72,7 +104,7 @@ export function createPrometheusMdOnlyHook(_ctx: PluginInput) { return } - if (!isAllowedFile(filePath)) { + if (!isAllowedFile(filePath, ctx.directory)) { log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, { sessionID: input.sessionID, tool: toolName,