fix(agents): normalize windows workspace path boundary checks

This commit is contained in:
Tak Hoffman
2026-03-02 10:39:06 -06:00
parent 3a08e69a05
commit cda2e2adf6
2 changed files with 77 additions and 3 deletions

View File

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

View File

@@ -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) {