fix(prometheus-md-only): cross-platform path validation for Windows support (#630) (#649)

Replace brittle string checks with robust path.resolve/relative validation:

- Fix Windows backslash paths (.sisyphus\plans\x.md) being incorrectly blocked
- Fix case-sensitive extension check (.MD now accepted)
- Add workspace confinement (block paths outside root even if containing .sisyphus)
- Block nested .sisyphus directories (only first segment allowed)
- Block path traversal attempts (.sisyphus/../secrets.md)
- Use ALLOWED_EXTENSIONS and ALLOWED_PATH_PREFIX constants (case-insensitive)

The new isAllowedFile() uses Node's path module for cross-platform compatibility
instead of string includes/endsWith which failed on Windows separators.
This commit is contained in:
Jeremy Gollehon
2026-01-10 19:21:50 -08:00
committed by GitHub
parent ce5315fbd0
commit 307d583ad6
3 changed files with 173 additions and 9 deletions

View File

@@ -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"]

View File

@@ -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()
})
})
})

View File

@@ -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,