import type { PluginInput } from "@opencode-ai/plugin" import { existsSync, readdirSync } from "node:fs" 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, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" export * from "./constants" /** * 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) * - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent) */ 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. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive) // This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md) if (!/\.sisyphus[/\\]/i.test(rel)) { 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 { if (!existsSync(MESSAGE_STORAGE)) return null const directPath = join(MESSAGE_STORAGE, sessionID) if (existsSync(directPath)) return directPath for (const dir of readdirSync(MESSAGE_STORAGE)) { const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) if (existsSync(sessionPath)) return sessionPath } return null } const TASK_TOOLS = ["sisyphus_task", "task", "call_omo_agent"] function getAgentFromMessageFiles(sessionID: string): string | undefined { const messageDir = getMessageDir(sessionID) if (!messageDir) return undefined return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent } function getAgentFromSession(sessionID: string): string | undefined { return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID) } export function createPrometheusMdOnlyHook(ctx: PluginInput) { return { "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string }, output: { args: Record; message?: string } ): Promise => { const agentName = getAgentFromSession(input.sessionID) if (!agentName || !PROMETHEUS_AGENTS.includes(agentName)) { return } const toolName = input.tool // Inject read-only warning for task tools called by Prometheus if (TASK_TOOLS.includes(toolName)) { const prompt = output.args.prompt as string | undefined if (prompt && !prompt.includes("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]")) { output.args.prompt = prompt + PLANNING_CONSULT_WARNING log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, { sessionID: input.sessionID, tool: toolName, agent: agentName, }) } return } 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, ctx.directory)) { 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, }) }, } }