Skip to content

Commit d3ff3f3

Browse files
fix: make path checks and tool output cross-platform on Windows (#32)
Three production bugs that broke on Windows because path separator handling assumed POSIX. CI is Linux-only so they were invisible there. 1. ingest.ts cloneRepo rejected every repo with `Unsafe clone path` because the safety guard checked `startsWith(`/`)` while `resolve()` produces `\` separators on Windows. Replaced with a path.relative-based helper that works on either platform. 2. web/routes.ts file-download endpoint returned 400 for every legitimate file for the same reason. Same fix. 3. tools.ts search shim `line.replace(repoPath + '/', '')` failed to strip the absolute repo path on Windows, leaking it into LLM-facing output. Switched to running ripgrep with cwd + relative target + --path-separator / so output is always relative forward-slash paths regardless of platform. list_files now also normalizes its displayed path to forward slashes for consistency with the tool description. Added two helpers in utils.ts: `isPathInsideDir` (resolves both paths internally to avoid misuse) and `toPosixPath` for display normalization, mirroring the pattern already used in impact.ts. Test fixes: 21 unit + 1 e2e test failed on Windows because they baked in POSIX-only assumptions. Switched literal `/repo/...` expectations to `path.resolve(...)` and normalized separators in fs mock predicates. The e2e helper now mirrors HOME to USERPROFILE on Windows so cache-redirection tests work cross-platform. All checks pass on Windows now: typecheck, lint, build, 965/965 unit tests, 9/9 CLI e2e tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ed366ef commit d3ff3f3

9 files changed

Lines changed: 99 additions & 30 deletions

File tree

src/ingest.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {
1919
MonorepoManager,
2020
RepoProvider,
2121
} from "./types.js";
22-
import { SKIP_DIRS } from "./utils.js";
22+
import { SKIP_DIRS, isPathInsideDir } from "./utils.js";
2323
import frameworkMaps from "./data/framework-maps.json" with { type: "json" };
2424

2525
const execFileAsync = promisify(execFile);
@@ -180,7 +180,7 @@ export async function cloneRepo(
180180
const safeOwner = repoInfo.owner.replace(/[^A-Za-z0-9._/-]/g, "_").replaceAll("/", "__");
181181
const safeRepo = repoInfo.repo.replace(/[^A-Za-z0-9._-]/g, "_");
182182
const clonePath = resolve(tempRoot, `${safeOwner}__${safeRepo}`);
183-
if (!clonePath.startsWith(`${tempRoot}/`) && clonePath !== tempRoot) {
183+
if (!isPathInsideDir(tempRoot, clonePath)) {
184184
throw new Error("Unsafe clone path");
185185
}
186186

src/tools.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { readFile, readdir, stat } from "fs/promises";
1010
import { isAbsolute, join, relative, resolve } from "path";
1111
import { execFile } from "child_process";
1212
import { promisify } from "util";
13-
import { SKIP_DIRS } from "./utils.js";
13+
import { SKIP_DIRS, toPosixPath } from "./utils.js";
1414

1515
const execFileAsync = promisify(execFile);
1616

@@ -175,7 +175,7 @@ function createListFilesTool(context: ToolContext): Tool<ListFilesToolArgs> {
175175
}
176176

177177
const prefix = entry.isDirectory() ? "[dir] " : "[file] ";
178-
results.push(`${prefix}${relativePath}`);
178+
results.push(`${prefix}${toPosixPath(relativePath)}`);
179179

180180
if (entry.isDirectory() && recursive) {
181181
await scanDir(entryPath, depth + 1);
@@ -230,25 +230,40 @@ function createSearchTool(context: ToolContext): Tool<SearchToolArgs> {
230230
},
231231
handler: async (args: SearchToolArgs) => {
232232
const { pattern, path = "", filePattern, maxResults = 50 } = args;
233+
// Validate the requested search path stays inside the repo (path traversal guard).
233234
const searchPath = safePath(context.repoPath, path);
235+
const repoRoot = resolve(context.repoPath);
236+
// Express the search target as a forward-slash relative path so ripgrep
237+
// emits matches with paths the LLM can feed back into read_file/list_files.
238+
const relSearchPath = relative(repoRoot, searchPath);
239+
const rgTarget = toPosixPath(relSearchPath) || ".";
234240

235241
context.onToolCall?.("search", { pattern, path, filePattern });
236242

237243
try {
238-
const rgArgs = ["--line-number", "--no-heading", "--max-count", String(maxResults)];
244+
// --path-separator / normalizes Windows backslashes to forward slashes
245+
// in printed paths; combined with cwd + a relative search target, the
246+
// output never contains absolute paths.
247+
const rgArgs = [
248+
"--line-number",
249+
"--no-heading",
250+
"--path-separator",
251+
"/",
252+
"--max-count",
253+
String(maxResults),
254+
];
239255
if (filePattern) {
240256
rgArgs.push("--glob", filePattern);
241257
}
242-
rgArgs.push(pattern, searchPath);
258+
rgArgs.push(pattern, rgTarget);
243259

244-
const { stdout } = await execFileAsync("rg", rgArgs, { timeout: 30000 });
245-
246-
// Make paths relative
247-
const lines = stdout.split("\n").filter(Boolean).map(line => {
248-
const relativeLine = line.replace(context.repoPath + "/", "");
249-
return relativeLine;
260+
const { stdout } = await execFileAsync("rg", rgArgs, {
261+
cwd: repoRoot,
262+
timeout: 30000,
250263
});
251264

265+
const lines = stdout.split("\n").filter(Boolean);
266+
252267
const result = lines.length > 0
253268
? lines.join("\n")
254269
: `No matches found for pattern: ${pattern}`;

src/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { readFile, access } from "fs/promises";
6+
import { isAbsolute, relative, resolve } from "path";
67

78
/**
89
* Common directory names to skip during repository traversal
@@ -47,3 +48,35 @@ export async function readFileSafe(filePath: string): Promise<string | null> {
4748
export function escapeRegex(str: string): string {
4849
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4950
}
51+
52+
/**
53+
* Convert a filesystem path to POSIX form (forward slashes).
54+
*
55+
* Use for paths that are surfaced to LLMs, written to JSON outputs,
56+
* or compared against repo-relative path strings — anywhere the
57+
* native Windows backslash separator would cause inconsistency.
58+
*
59+
* Does not perform path resolution; callers should resolve/normalize first
60+
* if needed.
61+
*/
62+
export function toPosixPath(p: string): string {
63+
return p.replace(/\\/g, "/");
64+
}
65+
66+
/**
67+
* Test whether `child` is the same path as `parent` or strictly inside it.
68+
*
69+
* Resolves both inputs internally so callers can pass relative or absolute
70+
* paths in any normalized form. Cross-platform: works correctly on Windows
71+
* where the path separator is `\` and on POSIX where it is `/`.
72+
*
73+
* NOTE: This is a lexical check only — it does not resolve symlinks. If a
74+
* path inside `parent` is a symlink pointing outside, this still returns
75+
* true. Use `fs.realpath` first if symlink containment matters.
76+
*/
77+
export function isPathInsideDir(parent: string, child: string): boolean {
78+
const resolvedParent = resolve(parent);
79+
const resolvedChild = resolve(child);
80+
const rel = relative(resolvedParent, resolvedChild);
81+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
82+
}

src/web/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { resolveRunConfiguration } from "../services/config-resolution.js";
1818
import { writeGeneratedOutputs } from "../services/output-writer.js";
1919
import type { BootcampOptions, RepoFacts } from "../types.js";
20+
import { isPathInsideDir } from "../utils.js";
2021

2122
/**
2223
* Progress event for SSE
@@ -444,7 +445,7 @@ export function registerRoutes(app: Application): void {
444445
try {
445446
const filePath = resolve(join(job.result.outputDir, filename));
446447
const outputDirResolved = resolve(job.result.outputDir);
447-
if (!filePath.startsWith(outputDirResolved + "/") && filePath !== outputDirResolved) {
448+
if (!isPathInsideDir(outputDirResolved, filePath)) {
448449
res.status(400).json({ error: "Invalid filename" });
449450
return;
450451
}

test/agent.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,7 +1383,7 @@ describe("prompt construction", () => {
13831383
it("uses repoPrompts option as override path", async () => {
13841384
const existsSyncMock = fs.existsSync as Mock;
13851385
existsSyncMock.mockImplementation((p: string) => {
1386-
return p.endsWith("/custom/my-prompts.md");
1386+
return p.replace(/\\/g, "/").endsWith("/custom/my-prompts.md");
13871387
});
13881388
(fs.readFileSync as Mock).mockReturnValue("Override prompt content");
13891389

@@ -1453,7 +1453,7 @@ describe("readCustomPrompt", () => {
14531453

14541454
it("uses override path when provided", () => {
14551455
(fs.existsSync as Mock).mockImplementation((p: string) =>
1456-
p.endsWith("/custom/prompts.md")
1456+
p.replace(/\\/g, "/").endsWith("/custom/prompts.md")
14571457
);
14581458
(fs.readFileSync as Mock).mockReturnValue("Override guidance");
14591459

@@ -1684,7 +1684,7 @@ describe("fast mode prompt construction", () => {
16841684
const readFileSyncMock = fs.readFileSync as Mock;
16851685

16861686
existsSyncMock.mockImplementation((p: string) => {
1687-
return p.endsWith("src/index.ts");
1687+
return p.replace(/\\/g, "/").endsWith("src/index.ts");
16881688
});
16891689
readFileSyncMock.mockReturnValue("export function main() {}");
16901690

test/e2e/helpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ function buildChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
2525
...env,
2626
};
2727

28+
// On Windows, Node's os.homedir() reads USERPROFILE (not HOME). Tests that
29+
// override HOME to redirect home-relative paths (e.g. ~/.cache) need the
30+
// same value mirrored to USERPROFILE for the override to take effect.
31+
if (process.platform === "win32" && env.HOME && !env.USERPROFILE) {
32+
childEnv.USERPROFILE = env.HOME;
33+
}
34+
2835
delete childEnv.NODE_OPTIONS;
2936
delete childEnv.VITEST;
3037

test/interactive.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { describe, it, expect, vi, beforeEach } from "vitest";
6+
import { join } from "path";
67
import type { RepoInfo, ScanResult, RepoFacts } from "../src/types.js";
78

89
const mockCreateSession = vi.fn();
@@ -179,7 +180,9 @@ describe("InteractiveSession", () => {
179180

180181
const outputPath = await session.saveTranscript("/tmp/output");
181182

182-
expect(outputPath).toBe("/tmp/output/TRANSCRIPT.md");
183+
// saveTranscript joins the output dir + filename via path.join, which uses
184+
// the platform-native separator. Compare with the same join.
185+
expect(outputPath).toBe(join("/tmp/output", "TRANSCRIPT.md"));
183186
expect(mockWriteFile).toHaveBeenCalledWith(
184187
outputPath,
185188
expect.stringContaining("# Interactive Session Transcript"),

test/repo-resolver.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ describe("resolveLocalPath", () => {
9898

9999
it("should keep absolute paths as-is", () => {
100100
const result = resolveLocalPath("/tmp/test-repo");
101-
expect(result).toBe("/tmp/test-repo");
101+
// path.resolve normalizes to platform-native form: "/tmp/test-repo" on POSIX,
102+
// "C:\\tmp\\test-repo" (or current-drive equivalent) on Windows.
103+
expect(result).toBe(resolve("/tmp/test-repo"));
102104
});
103105
});
104106

@@ -156,7 +158,8 @@ describe("resolveRepo", () => {
156158
const result = await resolveRepo(testLocalRepo);
157159

158160
expect(result.isLocal).toBe(true);
159-
expect(result.path).toBe(testLocalRepo);
161+
// resolveRepo normalizes to platform-native absolute form via path.resolve.
162+
expect(result.path).toBe(resolve(testLocalRepo));
160163
expect(result.repoName).toBe("test-local-repo-resolve");
161164
expect(result.repoInfo.owner).toBe("local");
162165
expect(result.repoInfo.repo).toBe("test-local-repo-resolve");

test/tools.test.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { describe, it, expect, vi, beforeEach } from "vitest";
6-
import { join } from "path";
6+
import { resolve } from "path";
77

88
// Mock fs/promises
99
vi.mock("fs/promises", () => ({
@@ -28,9 +28,11 @@ const mockExecFile = vi.mocked(execFile);
2828

2929
describe("safePath", () => {
3030
it("allows valid relative paths", () => {
31-
expect(safePath("/repo", "src/index.ts")).toBe("/repo/src/index.ts");
32-
expect(safePath("/repo", "package.json")).toBe("/repo/package.json");
33-
expect(safePath("/repo", "src/deep/nested/file.ts")).toBe("/repo/src/deep/nested/file.ts");
31+
expect(safePath("/repo", "src/index.ts")).toBe(resolve("/repo", "src/index.ts"));
32+
expect(safePath("/repo", "package.json")).toBe(resolve("/repo", "package.json"));
33+
expect(safePath("/repo", "src/deep/nested/file.ts")).toBe(
34+
resolve("/repo", "src/deep/nested/file.ts"),
35+
);
3436
});
3537

3638
it("rejects traversal with ..", () => {
@@ -45,16 +47,16 @@ describe("safePath", () => {
4547
});
4648

4749
it("allows paths that contain .. but stay within root", () => {
48-
expect(safePath("/repo", "src/../package.json")).toBe("/repo/package.json");
50+
expect(safePath("/repo", "src/../package.json")).toBe(resolve("/repo", "package.json"));
4951
});
5052

5153
it("allows empty path (resolves to root)", () => {
52-
expect(safePath("/repo", "")).toBe("/repo");
54+
expect(safePath("/repo", "")).toBe(resolve("/repo"));
5355
});
5456

5557
it("handles paths with encoded characters safely", () => {
5658
// These are just regular directory names, not traversal
57-
expect(safePath("/repo", "dir%2F..%2Fetc")).toBe("/repo/dir%2F..%2Fetc");
59+
expect(safePath("/repo", "dir%2F..%2Fetc")).toBe(resolve("/repo", "dir%2F..%2Fetc"));
5860
});
5961
});
6062

@@ -109,7 +111,7 @@ describe("read_file tool", () => {
109111
const result = await tool.handler({ path: "src/index.ts" }, {} as any);
110112

111113
expect(mockReadFile).toHaveBeenCalledWith(
112-
join("/test/repo", "src/index.ts"),
114+
resolve("/test/repo", "src/index.ts"),
113115
"utf-8",
114116
);
115117
expect(result).toEqual({
@@ -520,13 +522,16 @@ describe("search tool", () => {
520522
it("searches in a subdirectory", async () => {
521523
const ctx = makeContext();
522524
const tool = getTool(ctx, "search");
523-
mockExecFileSuccess("/test/repo/src/a.ts:1:hello\n");
525+
mockExecFileSuccess("src/a.ts:1:hello\n");
524526

525527
await tool.handler({ pattern: "hello", path: "src" }, {} as any);
526528

527529
const call = mockExecFile.mock.calls[0];
528530
const args = call[1] as string[];
529-
expect(args).toContain(join("/test/repo", "src"));
531+
// Search target is now a forward-slash relative path; cwd handles the absolute root.
532+
expect(args).toContain("src");
533+
const opts = call[2] as { cwd?: string } | undefined;
534+
expect(opts?.cwd).toBe(resolve("/test/repo"));
530535
});
531536

532537
it("returns no matches message when ripgrep exits with code 1", async () => {
@@ -568,8 +573,10 @@ describe("search tool", () => {
568573
it("makes paths relative in output", async () => {
569574
const ctx = makeContext();
570575
const tool = getTool(ctx, "search");
576+
// Real ripgrep, with cwd set and --path-separator /, emits relative
577+
// forward-slash paths. The mock reflects that contract.
571578
mockExecFileSuccess(
572-
"/test/repo/deep/nested/file.ts:42:found it\n",
579+
"deep/nested/file.ts:42:found it\n",
573580
);
574581

575582
const result = await tool.handler({ pattern: "found" }, {} as any);

0 commit comments

Comments
 (0)