Skip to content

Commit ef89b48

Browse files
fix(agents): normalize windows workspace path boundary checks (#30766)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
1 parent a183656 commit ef89b48

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

src/agents/path-policy.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const resolveSandboxInputPathMock = vi.hoisted(() => vi.fn());
4+
5+
vi.mock("./sandbox-paths.js", () => ({
6+
resolveSandboxInputPath: resolveSandboxInputPathMock,
7+
}));
8+
9+
import { toRelativeWorkspacePath } from "./path-policy.js";
10+
11+
describe("toRelativeWorkspacePath (windows semantics)", () => {
12+
beforeEach(() => {
13+
resolveSandboxInputPathMock.mockReset();
14+
resolveSandboxInputPathMock.mockImplementation((filePath: string) => filePath);
15+
});
16+
17+
it("accepts windows paths with mixed separators and case", () => {
18+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
19+
try {
20+
const root = "C:\\Users\\User\\OpenClaw";
21+
const candidate = "c:/users/user/openclaw/memory/log.txt";
22+
expect(toRelativeWorkspacePath(root, candidate)).toBe("memory\\log.txt");
23+
} finally {
24+
platformSpy.mockRestore();
25+
}
26+
});
27+
28+
it("rejects windows paths outside workspace root", () => {
29+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
30+
try {
31+
const root = "C:\\Users\\User\\OpenClaw";
32+
const candidate = "C:\\Users\\User\\Other\\log.txt";
33+
expect(() => toRelativeWorkspacePath(root, candidate)).toThrow("Path escapes workspace root");
34+
} finally {
35+
platformSpy.mockRestore();
36+
}
37+
});
38+
});

src/agents/path-policy.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,51 @@ type RelativePathOptions = {
88
includeRootInError?: boolean;
99
};
1010

11+
function normalizeWindowsPathForComparison(input: string): string {
12+
let normalized = path.win32.normalize(input);
13+
if (normalized.startsWith("\\\\?\\")) {
14+
normalized = normalized.slice(4);
15+
if (normalized.toUpperCase().startsWith("UNC\\")) {
16+
normalized = `\\\\${normalized.slice(4)}`;
17+
}
18+
}
19+
return normalized.replaceAll("/", "\\").toLowerCase();
20+
}
21+
1122
function toRelativePathUnderRoot(params: {
1223
root: string;
1324
candidate: string;
1425
options?: RelativePathOptions;
1526
}): string {
16-
const rootResolved = path.resolve(params.root);
17-
const resolvedCandidate = path.resolve(
18-
resolveSandboxInputPath(params.candidate, params.options?.cwd ?? params.root),
27+
const resolvedInput = resolveSandboxInputPath(
28+
params.candidate,
29+
params.options?.cwd ?? params.root,
1930
);
31+
32+
if (process.platform === "win32") {
33+
const rootResolved = path.win32.resolve(params.root);
34+
const resolvedCandidate = path.win32.resolve(resolvedInput);
35+
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
36+
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
37+
const relative = path.win32.relative(rootForCompare, targetForCompare);
38+
if (relative === "" || relative === ".") {
39+
if (params.options?.allowRoot) {
40+
return "";
41+
}
42+
const boundary = params.options?.boundaryLabel ?? "workspace root";
43+
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
44+
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
45+
}
46+
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
47+
const boundary = params.options?.boundaryLabel ?? "workspace root";
48+
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
49+
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
50+
}
51+
return relative;
52+
}
53+
54+
const rootResolved = path.resolve(params.root);
55+
const resolvedCandidate = path.resolve(resolvedInput);
2056
const relative = path.relative(rootResolved, resolvedCandidate);
2157
if (relative === "" || relative === ".") {
2258
if (params.options?.allowRoot) {

0 commit comments

Comments
 (0)