diff --git a/apps/opencode-router/src/bridge.ts b/apps/opencode-router/src/bridge.ts index 50cc6525..b06c3fed 100644 --- a/apps/opencode-router/src/bridge.ts +++ b/apps/opencode-router/src/bridge.ts @@ -15,6 +15,7 @@ import { startHealthServer, type HealthSnapshot } from "./health.js"; import { type InboundMessagePart, type MessageDeliveryResult, type OutboundMessagePart, normalizeOutboundParts, summarizeInboundPartsForPrompt, summarizeInboundPartsForReporter, textFromInboundParts } from "./media.js"; import { MediaStore } from "./media-store.js"; import { buildPermissionRules, createClient } from "./opencode.js"; +import { isWithinWorkspaceRootPath, normalizeScopedDirectoryPath } from "./path-scope.js"; import { chunkText, formatInputSummary, truncateText } from "./text.js"; import { createSlackAdapter } from "./slack.js"; import { createTelegramAdapter, isTelegramPeerId } from "./telegram.js"; @@ -442,25 +443,17 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri const formatPeer = (_channel: ChannelName, peerId: string) => peerId; - const normalizeDirectory = (input: string) => { - const trimmed = input.trim(); - if (!trimmed) return ""; - const unified = trimmed.replace(/\\/g, "/"); - const withoutTrailing = unified.replace(/\/+$/, ""); - const normalized = withoutTrailing || "/"; - return process.platform === "win32" ? normalized.toLowerCase() : normalized; - }; + const normalizeDirectory = (input: string) => + normalizeScopedDirectoryPath(input, process.platform); const workspaceRootNormalized = normalizeDirectory(workspaceRoot); const isWithinWorkspaceRoot = (candidate: string) => { - const resolved = resolve(candidate || workspaceRoot); - const relativePath = relative(workspaceRoot, resolved); - if (!relativePath) return true; - if (relativePath === ".") return true; - if (relativePath.startsWith("..") || isAbsolute(relativePath)) return false; - const boundary = workspaceRoot.endsWith(sep) ? workspaceRoot : `${workspaceRoot}${sep}`; - return resolved === workspaceRoot || resolved.startsWith(boundary); + return isWithinWorkspaceRootPath({ + workspaceRoot, + candidate, + platform: process.platform, + }); }; const resolveScopedDirectory = (input: string): { ok: true; directory: string } | { ok: false; error: string } => { diff --git a/apps/opencode-router/src/path-scope.ts b/apps/opencode-router/src/path-scope.ts new file mode 100644 index 00000000..9cc337e2 --- /dev/null +++ b/apps/opencode-router/src/path-scope.ts @@ -0,0 +1,42 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +export function normalizeScopedDirectoryPath(input: string, platform = process.platform) { + const trimmed = input.trim(); + if (!trimmed) return ""; + const withoutVerbatim = /^\\\\\?\\UNC[\\/]/i.test(trimmed) + ? `\\${trimmed.slice(8)}` + : /^\\\\\?\\[a-zA-Z]:[\\/]/.test(trimmed) + ? trimmed.slice(4) + : trimmed; + const unified = withoutVerbatim.replace(/\\/g, "/"); + const withoutTrailing = unified.replace(/\/+$/, ""); + const normalized = withoutTrailing || "/"; + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +export function isWithinWorkspaceRootPath(input: { + workspaceRoot: string; + candidate: string; + platform?: NodeJS.Platform; +}) { + const platform = input.platform ?? process.platform; + const rootForComparison = + platform === "win32" + ? normalizeScopedDirectoryPath(input.workspaceRoot, platform) + : input.workspaceRoot; + const resolved = resolve(input.candidate || input.workspaceRoot); + const resolvedForComparison = + platform === "win32" + ? normalizeScopedDirectoryPath(resolved, platform) + : resolved; + const relativePath = relative(rootForComparison, resolvedForComparison); + if (!relativePath || relativePath === ".") return true; + if (relativePath.startsWith("..") || isAbsolute(relativePath)) return false; + const boundary = rootForComparison.endsWith("/") + ? rootForComparison + : `${rootForComparison}/`; + return ( + resolvedForComparison === rootForComparison || + resolvedForComparison.startsWith(boundary) + ); +} diff --git a/apps/opencode-router/test/path-scope.test.js b/apps/opencode-router/test/path-scope.test.js new file mode 100644 index 00000000..45ec20d3 --- /dev/null +++ b/apps/opencode-router/test/path-scope.test.js @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isWithinWorkspaceRootPath, + normalizeScopedDirectoryPath, +} from "../dist/path-scope.js"; + +test("normalizeScopedDirectoryPath strips Windows verbatim prefixes", () => { + const workspaceRoot = String.raw`G:\project\openwork_project`; + const candidate = String.raw`\\?\G:\project\openwork_project`; + + assert.equal( + normalizeScopedDirectoryPath(workspaceRoot, "win32"), + "g:/project/openwork_project", + ); + assert.equal( + normalizeScopedDirectoryPath(candidate, "win32"), + "g:/project/openwork_project", + ); +}); + +test("isWithinWorkspaceRootPath accepts Windows verbatim aliases for workspace root", () => { + const workspaceRoot = String.raw`G:\project\openwork_project`; + const candidate = String.raw`\\?\G:\project\openwork_project`; + + assert.equal( + isWithinWorkspaceRootPath({ + workspaceRoot, + candidate, + platform: "win32", + }), + true, + ); +}); + +test("isWithinWorkspaceRootPath still rejects directories outside the workspace root", () => { + const workspaceRoot = String.raw`G:\project\openwork_project`; + const candidate = String.raw`\\?\G:\project\outside`; + + assert.equal( + isWithinWorkspaceRootPath({ + workspaceRoot, + candidate, + platform: "win32", + }), + false, + ); +});