Skip to content

Commit c721d4f

Browse files
committed
fix(prometheus-md-only): cross-platform path validation for Windows support (#630)
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.
1 parent 8b12257 commit c721d4f

File tree

3 files changed

+173
-9
lines changed

3 files changed

+173
-9
lines changed

src/hooks/prometheus-md-only/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"]
44

55
export const ALLOWED_EXTENSIONS = [".md"]
66

7-
export const ALLOWED_PATH_PREFIX = ".sisyphus/"
7+
export const ALLOWED_PATH_PREFIX = ".sisyphus"
88

99
export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit"]
1010

src/hooks/prometheus-md-only/index.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe("prometheus-md-only", () => {
7070
callID: "call-1",
7171
}
7272
const output = {
73-
args: { filePath: "/project/.sisyphus/plans/work-plan.md" },
73+
args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" },
7474
}
7575

7676
// #when / #then
@@ -295,4 +295,136 @@ describe("prometheus-md-only", () => {
295295
).resolves.toBeUndefined()
296296
})
297297
})
298+
299+
describe("cross-platform path validation", () => {
300+
beforeEach(() => {
301+
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
302+
})
303+
304+
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
305+
// #given
306+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
307+
const input = {
308+
tool: "Write",
309+
sessionID: TEST_SESSION_ID,
310+
callID: "call-1",
311+
}
312+
const output = {
313+
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
314+
}
315+
316+
// #when / #then
317+
await expect(
318+
hook["tool.execute.before"](input, output)
319+
).resolves.toBeUndefined()
320+
})
321+
322+
test("should allow mixed separator paths under .sisyphus/", async () => {
323+
// #given
324+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
325+
const input = {
326+
tool: "Write",
327+
sessionID: TEST_SESSION_ID,
328+
callID: "call-1",
329+
}
330+
const output = {
331+
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
332+
}
333+
334+
// #when / #then
335+
await expect(
336+
hook["tool.execute.before"](input, output)
337+
).resolves.toBeUndefined()
338+
})
339+
340+
test("should allow uppercase .MD extension", async () => {
341+
// #given
342+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
343+
const input = {
344+
tool: "Write",
345+
sessionID: TEST_SESSION_ID,
346+
callID: "call-1",
347+
}
348+
const output = {
349+
args: { filePath: ".sisyphus/plans/work-plan.MD" },
350+
}
351+
352+
// #when / #then
353+
await expect(
354+
hook["tool.execute.before"](input, output)
355+
).resolves.toBeUndefined()
356+
})
357+
358+
test("should block paths outside workspace root even if containing .sisyphus", async () => {
359+
// #given
360+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
361+
const input = {
362+
tool: "Write",
363+
sessionID: TEST_SESSION_ID,
364+
callID: "call-1",
365+
}
366+
const output = {
367+
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
368+
}
369+
370+
// #when / #then
371+
await expect(
372+
hook["tool.execute.before"](input, output)
373+
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
374+
})
375+
376+
test("should block nested .sisyphus directories", async () => {
377+
// #given
378+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
379+
const input = {
380+
tool: "Write",
381+
sessionID: TEST_SESSION_ID,
382+
callID: "call-1",
383+
}
384+
const output = {
385+
args: { filePath: "src/.sisyphus/plans/x.md" },
386+
}
387+
388+
// #when / #then
389+
await expect(
390+
hook["tool.execute.before"](input, output)
391+
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
392+
})
393+
394+
test("should block path traversal attempts", async () => {
395+
// #given
396+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
397+
const input = {
398+
tool: "Write",
399+
sessionID: TEST_SESSION_ID,
400+
callID: "call-1",
401+
}
402+
const output = {
403+
args: { filePath: ".sisyphus/../secrets.md" },
404+
}
405+
406+
// #when / #then
407+
await expect(
408+
hook["tool.execute.before"](input, output)
409+
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
410+
})
411+
412+
test("should allow case-insensitive .SISYPHUS directory", async () => {
413+
// #given
414+
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
415+
const input = {
416+
tool: "Write",
417+
sessionID: TEST_SESSION_ID,
418+
callID: "call-1",
419+
}
420+
const output = {
421+
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
422+
}
423+
424+
// #when / #then
425+
await expect(
426+
hook["tool.execute.before"](input, output)
427+
).resolves.toBeUndefined()
428+
})
429+
})
298430
})

src/hooks/prometheus-md-only/index.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
11
import type { PluginInput } from "@opencode-ai/plugin"
22
import { existsSync, readdirSync } from "node:fs"
3-
import { join } from "node:path"
3+
import { join, resolve, relative, isAbsolute } from "node:path"
44
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants"
55
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
66
import { log } from "../../shared/logger"
77

88
export * from "./constants"
99

10-
function isAllowedFile(filePath: string): boolean {
11-
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext => filePath.endsWith(ext))
12-
const isInAllowedPath = filePath.includes(ALLOWED_PATH_PREFIX)
13-
return hasAllowedExtension && isInAllowedPath
10+
/**
11+
* Cross-platform path validator for Prometheus file writes.
12+
* Uses path.resolve/relative instead of string matching to handle:
13+
* - Windows backslashes (e.g., .sisyphus\\plans\\x.md)
14+
* - Mixed separators (e.g., .sisyphus\\plans/x.md)
15+
* - Case-insensitive directory/extension matching
16+
* - Workspace confinement (blocks paths outside root or via traversal)
17+
*/
18+
function isAllowedFile(filePath: string, workspaceRoot: string): boolean {
19+
// 1. Resolve to absolute path
20+
const resolved = resolve(workspaceRoot, filePath)
21+
22+
// 2. Get relative path from workspace root
23+
const rel = relative(workspaceRoot, resolved)
24+
25+
// 3. Reject if escapes root (starts with ".." or is absolute)
26+
if (rel.startsWith("..") || isAbsolute(rel)) {
27+
return false
28+
}
29+
30+
// 4. Split by both separators and check first segment matches ALLOWED_PATH_PREFIX (case-insensitive)
31+
// Guard: if rel is empty (filePath === workspaceRoot), segments[0] would be "" — reject
32+
const segments = rel.split(/[/\\]/)
33+
if (!segments[0] || segments[0].toLowerCase() !== ALLOWED_PATH_PREFIX.toLowerCase()) {
34+
return false
35+
}
36+
37+
// 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive)
38+
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(
39+
ext => resolved.toLowerCase().endsWith(ext.toLowerCase())
40+
)
41+
if (!hasAllowedExtension) {
42+
return false
43+
}
44+
45+
return true
1446
}
1547

1648
function getMessageDir(sessionID: string): string | null {
@@ -35,7 +67,7 @@ function getAgentFromSession(sessionID: string): string | undefined {
3567
return findNearestMessageWithFields(messageDir)?.agent
3668
}
3769

38-
export function createPrometheusMdOnlyHook(_ctx: PluginInput) {
70+
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
3971
return {
4072
"tool.execute.before": async (
4173
input: { tool: string; sessionID: string; callID: string },
@@ -72,7 +104,7 @@ export function createPrometheusMdOnlyHook(_ctx: PluginInput) {
72104
return
73105
}
74106

75-
if (!isAllowedFile(filePath)) {
107+
if (!isAllowedFile(filePath, ctx.directory)) {
76108
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
77109
sessionID: input.sessionID,
78110
tool: toolName,

0 commit comments

Comments
 (0)