diff --git a/src/agents/path-policy.test.ts b/src/agents/path-policy.test.ts new file mode 100644 index 00000000000..3217cdf4792 --- /dev/null +++ b/src/agents/path-policy.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveSandboxInputPathMock = vi.hoisted(() => vi.fn()); + +vi.mock("./sandbox-paths.js", () => ({ + resolveSandboxInputPath: resolveSandboxInputPathMock, +})); + +import { toRelativeWorkspacePath } from "./path-policy.js"; + +describe("toRelativeWorkspacePath (windows semantics)", () => { + beforeEach(() => { + resolveSandboxInputPathMock.mockReset(); + resolveSandboxInputPathMock.mockImplementation((filePath: string) => filePath); + }); + + it("accepts windows paths with mixed separators and case", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const root = "C:\\Users\\User\\OpenClaw"; + const candidate = "c:/users/user/openclaw/memory/log.txt"; + expect(toRelativeWorkspacePath(root, candidate)).toBe("memory\\log.txt"); + } finally { + platformSpy.mockRestore(); + } + }); + + it("rejects windows paths outside workspace root", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const root = "C:\\Users\\User\\OpenClaw"; + const candidate = "C:\\Users\\User\\Other\\log.txt"; + expect(() => toRelativeWorkspacePath(root, candidate)).toThrow("Path escapes workspace root"); + } finally { + platformSpy.mockRestore(); + } + }); +}); diff --git a/src/agents/path-policy.ts b/src/agents/path-policy.ts index f4eb8e32292..042cff7ff4f 100644 --- a/src/agents/path-policy.ts +++ b/src/agents/path-policy.ts @@ -8,15 +8,51 @@ type RelativePathOptions = { includeRootInError?: boolean; }; +function normalizeWindowsPathForComparison(input: string): string { + let normalized = path.win32.normalize(input); + if (normalized.startsWith("\\\\?\\")) { + normalized = normalized.slice(4); + if (normalized.toUpperCase().startsWith("UNC\\")) { + normalized = `\\\\${normalized.slice(4)}`; + } + } + return normalized.replaceAll("/", "\\").toLowerCase(); +} + function toRelativePathUnderRoot(params: { root: string; candidate: string; options?: RelativePathOptions; }): string { - const rootResolved = path.resolve(params.root); - const resolvedCandidate = path.resolve( - resolveSandboxInputPath(params.candidate, params.options?.cwd ?? params.root), + const resolvedInput = resolveSandboxInputPath( + params.candidate, + params.options?.cwd ?? params.root, ); + + if (process.platform === "win32") { + const rootResolved = path.win32.resolve(params.root); + const resolvedCandidate = path.win32.resolve(resolvedInput); + const rootForCompare = normalizeWindowsPathForComparison(rootResolved); + const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate); + const relative = path.win32.relative(rootForCompare, targetForCompare); + if (relative === "" || relative === ".") { + if (params.options?.allowRoot) { + return ""; + } + const boundary = params.options?.boundaryLabel ?? "workspace root"; + const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : ""; + throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`); + } + if (relative.startsWith("..") || path.win32.isAbsolute(relative)) { + const boundary = params.options?.boundaryLabel ?? "workspace root"; + const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : ""; + throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`); + } + return relative; + } + + const rootResolved = path.resolve(params.root); + const resolvedCandidate = path.resolve(resolvedInput); const relative = path.relative(rootResolved, resolvedCandidate); if (relative === "" || relative === ".") { if (params.options?.allowRoot) {