Skip to content

Commit 837ba00

Browse files
Merge pull request claude-code-best#1287 from Ericcc-Ma/fix/validate-path-mingw-normalization
fix(permissions): normalize MinGW paths in validatePath before resolu…
2 parents 4f5ae5e + 75c0376 commit 837ba00

2 files changed

Lines changed: 231 additions & 3 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, expect, mock, test } from 'bun:test'
2+
import { logMock } from '../../../../tests/mocks/log'
3+
import { debugMock } from '../../../../tests/mocks/debug'
4+
5+
// Cut the bootstrap/state dependency chain (mock.module requirement).
6+
mock.module('src/utils/log.ts', logMock)
7+
mock.module('src/utils/debug.ts', debugMock)
8+
mock.module('bun:bundle', () => ({
9+
feature: (_name: string) => false,
10+
}))
11+
12+
// MACRO is a build-time define injected by `bun --define` (see
13+
// scripts/dev.ts → -d flags). Without it, `declare const MACRO` references
14+
// in source code resolve to `undefined` at runtime and crash any function
15+
// that touches `MACRO.VERSION` (e.g. `getBundledSkillsRoot` via
16+
// `checkReadableInternalPath`).
17+
// Setting it on globalThis lets the bare `MACRO` identifier resolve at
18+
// runtime in tests.
19+
;(globalThis as unknown as { MACRO: { VERSION: string } }).MACRO = {
20+
VERSION: 'test',
21+
}
22+
23+
const { validatePath } = await import('../pathValidation.js')
24+
const { getEmptyToolPermissionContext } = await import('../../../Tool.js')
25+
26+
function makeContext(): ReturnType<typeof getEmptyToolPermissionContext> {
27+
return getEmptyToolPermissionContext()
28+
}
29+
30+
const isWindows = process.platform === 'win32'
31+
const describeIfWindows = isWindows ? describe : describe.skip
32+
33+
// ─── MinGW path normalization (Windows) ──────────────────────────────────
34+
//
35+
// These tests pin the fix for a sandbox-escape class: on Windows, the Git
36+
// Bash shell interprets paths like `/c/Users/foo/bar.txt` as `C:\Users\foo\bar.txt`
37+
// (the C: drive). However, the Node `path` module treats such paths as
38+
// drive-relative absolute paths on the current drive, so:
39+
// - path.isAbsolute('/c/Users/foo/bar.txt') === true (on Windows)
40+
// - path.resolve('D:\\project', '/c/Users/foo/bar.txt')
41+
// === 'D:\\c\\Users\\foo\\bar.txt' (on Windows)
42+
//
43+
// That means without normalization, validatePath would compare
44+
// `D:\c\Users\foo\bar.txt` against the allowed-directories list, while
45+
// Git Bash actually writes to `C:\Users\foo\bar.txt` — a completely
46+
// different filesystem location. This is a TOCTOU/sandbox-escape bug.
47+
//
48+
// The fix runs `posixPathToWindowsPath` on Windows before resolution,
49+
// converting `/c/...` and `/cygdrive/c/...` to their `C:\...` form so the
50+
// validator's path space matches the shell's.
51+
/**
52+
* Tests that `validatePath` normalizes MinGW-style absolute paths
53+
* (`/c/Users/foo`, `/cygdrive/c/Users/foo`) to Windows paths
54+
* (`C:\\Users\\foo`) on Windows. Without this, the validator runs in
55+
* Windows host path space while the Git Bash shell runs in MinGW path
56+
* space, leading to a sandbox-escape class — see the comment block
57+
* at the top of this file for the full security rationale.
58+
*/
59+
describeIfWindows('validatePath MinGW path normalization', () => {
60+
test('converts /c/Users/foo/file.txt to C:\\Users\\foo\\file.txt', () => {
61+
const result = validatePath(
62+
'/c/Users/foo/file.txt',
63+
'D:\\project',
64+
makeContext(),
65+
'read',
66+
)
67+
// resolvedPath is the canonical form the validator (and ultimately the
68+
// shell) operates on. It must be the Windows-style path, not the
69+
// drive-relative form `D:\c\Users\foo\file.txt`.
70+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe(
71+
'C:\\Users\\foo\\file.txt',
72+
)
73+
})
74+
75+
test('converts /cygdrive/c/Users/foo/file.txt to C:\\Users\\foo\\file.txt', () => {
76+
const result = validatePath(
77+
'/cygdrive/c/Users/foo/file.txt',
78+
'D:\\project',
79+
makeContext(),
80+
'read',
81+
)
82+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe(
83+
'C:\\Users\\foo\\file.txt',
84+
)
85+
})
86+
87+
test('uppercases the drive letter', () => {
88+
const result = validatePath(
89+
'/d/work/file.txt',
90+
'C:\\project',
91+
makeContext(),
92+
'read',
93+
)
94+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe('D:\\work\\file.txt')
95+
})
96+
97+
test('preserves Windows paths unchanged', () => {
98+
// An already-Windows path should not be touched.
99+
const result = validatePath(
100+
'C:\\Users\\foo\\file.txt',
101+
'D:\\project',
102+
makeContext(),
103+
'read',
104+
)
105+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe(
106+
'C:\\Users\\foo\\file.txt',
107+
)
108+
})
109+
110+
test('preserves relative paths (just flips slashes)', () => {
111+
// Relative paths are not MinGW absolute paths; the conversion
112+
// should be a no-op aside from slash direction. The path is then
113+
// resolved against cwd by `validatePath`, which is expected behavior.
114+
const result = validatePath(
115+
'src/file.txt',
116+
'D:\\project',
117+
makeContext(),
118+
'read',
119+
)
120+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe(
121+
'D:\\project\\src\\file.txt',
122+
)
123+
})
124+
125+
test('handles bare drive mount (no trailing path)', () => {
126+
const result = validatePath('/c', 'D:\\project', makeContext(), 'read')
127+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe('C:\\')
128+
})
129+
130+
test('handles drive root with trailing slash', () => {
131+
const result = validatePath('/c/', 'D:\\project', makeContext(), 'read')
132+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe('C:\\')
133+
})
134+
135+
test('handles deeply nested MinGW paths', () => {
136+
const result = validatePath(
137+
'/c/Users/me/Documents/project/src/index.ts',
138+
'D:\\project',
139+
makeContext(),
140+
'read',
141+
)
142+
expect(result.resolvedPath.replace(/\//g, '\\')).toBe(
143+
'C:\\Users\\me\\Documents\\project\\src\\index.ts',
144+
)
145+
})
146+
})
147+
148+
// ─── Sandbox escape regression (Windows) ─────────────────────────────────
149+
//
150+
// This is the bug the MinGW-normalization fix exists to prevent: without
151+
// it, the validator compares `<currentDrive>:\c\Users\foo\file.txt` against
152+
// the allowed dirs, while bash writes to `C:\Users\foo\file.txt`. With the
153+
// fix, both sides of the comparison use the same `C:\Users\foo\file.txt`
154+
// location.
155+
//
156+
// We pin this by setting up a context where:
157+
// - cwd is `D:\project` (and D:\project is allowed)
158+
// - `C:\Users\foo` is NOT in any allowed directory
159+
// Then we check that `/c/Users/foo/sensitive.txt` is denied with a
160+
// resolvedPath pointing at C:\Users\foo — proving the validator now sees
161+
// the same path the shell will write to.
162+
/**
163+
* Regression tests for the sandbox-escape class the MinGW-normalization
164+
* fix prevents. Without the fix, a MinGW-style path like
165+
* `/c/Users/foo/sensitive.txt` is resolved (by `path.resolve`) against
166+
* the current drive (`D:\c\Users\foo\sensitive.txt`) and compared to
167+
* the allowed-directories list — while Git Bash actually writes to
168+
* `C:\Users\foo\sensitive.txt`. With the fix, both sides of the
169+
* comparison use the same Windows path so a path the shell will write
170+
* to but isn't in any allowed dir is denied.
171+
*/
172+
describeIfWindows('validatePath sandbox escape regression', () => {
173+
test('MinGW path that escapes allowed dirs is denied at correct location', () => {
174+
// Without the fix, this would resolve to `D:\c\Users\foo\sensitive.txt`
175+
// and (if D:\ is broadly allowed) pass validation, while bash actually
176+
// writes to `C:\Users\foo\sensitive.txt`. With the fix, the validator
177+
// sees the correct path and denies it because C:\Users\foo is not in
178+
// any allowed directory.
179+
const result = validatePath(
180+
'/c/Users/foo/sensitive.txt',
181+
'D:\\project',
182+
makeContext(),
183+
'create',
184+
)
185+
expect(result.allowed).toBe(false)
186+
// The resolvedPath should be at C:\Users\foo — not D:\c\Users\foo.
187+
const normalized = result.resolvedPath.replace(/\//g, '\\')
188+
expect(normalized.startsWith('C:\\Users\\foo')).toBe(true)
189+
expect(normalized.startsWith('D:\\c\\')).toBe(false)
190+
})
191+
192+
test('cygdrive path that escapes allowed dirs is denied at correct location', () => {
193+
const result = validatePath(
194+
'/cygdrive/c/Users/foo/sensitive.txt',
195+
'D:\\project',
196+
makeContext(),
197+
'create',
198+
)
199+
expect(result.allowed).toBe(false)
200+
const normalized = result.resolvedPath.replace(/\//g, '\\')
201+
expect(normalized.startsWith('C:\\Users\\foo')).toBe(true)
202+
})
203+
})

src/utils/permissions/pathValidation.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { homedir } from 'os'
33
import { dirname, isAbsolute, resolve } from 'path'
44
import type { ToolPermissionContext } from '../../Tool.js'
55
import { getPlatform } from '../../utils/platform.js'
6+
import { posixPathToWindowsPath } from '../windowsPaths.js'
67
import {
78
getFsImplementation,
89
getPathsForPermissionCheck,
@@ -35,6 +36,12 @@ export type ResolvedPathCheckResult = PathCheckResult & {
3536
resolvedPath: string
3637
}
3738

39+
/**
40+
* Format a list of working directories for inclusion in permission-prompt
41+
* messages. When the list is short (≤ {@link MAX_DIRS_TO_LIST}) all
42+
* directories are listed; when longer, a truncated form is shown with
43+
* the remaining count.
44+
*/
3845
export function formatDirectoryList(directories: string[]): string {
3946
const dirCount = directories.length
4047

@@ -472,9 +479,27 @@ export function validatePath(
472479
}
473480

474481
// Resolve path
475-
const absolutePath = isAbsolute(cleanPath)
476-
? cleanPath
477-
: resolve(cwd, cleanPath)
482+
// SECURITY: On Windows, normalize MinGW-style absolute paths
483+
// (`/c/Users/foo`, `/cygdrive/c/Users/foo`) to Windows paths
484+
// (`C:\Users\foo`) BEFORE the isAbsolute/resolve step below.
485+
//
486+
// Without this, `path.isAbsolute('/c/Users/foo')` returns true on
487+
// Windows (interpreted as a drive-relative absolute path) and
488+
// `path.resolve(cwd, '/c/Users/foo')` produces `<currentDrive>:\c\Users\foo`
489+
// — a completely different filesystem location from what Git Bash
490+
// actually writes to (`C:\Users\foo`). The validator would compare
491+
// the wrong path against the allowed-directories list, enabling a
492+
// sandbox escape where `/c/Users/foo/sensitive.txt` passes validation
493+
// when `C:\Users\foo\sensitive.txt` is not in any allowed dir.
494+
//
495+
// `posixPathToWindowsPath` is a no-op for already-Windows paths and
496+
// for relative paths (it just flips slashes), so it's safe to apply
497+
// unconditionally on Windows.
498+
const pathForResolve =
499+
getPlatform() === 'windows' ? posixPathToWindowsPath(cleanPath) : cleanPath
500+
const absolutePath = isAbsolute(pathForResolve)
501+
? pathForResolve
502+
: resolve(cwd, pathForResolve)
478503
const { resolvedPath, isCanonical } = safeResolvePath(
479504
getFsImplementation(),
480505
absolutePath,

0 commit comments

Comments
 (0)