Skip to content

Commit a0bb8b6

Browse files
fix(security): address review on workspace-boundary enforcement (#169)
- GenerateImageTool: apply the workspace-boundary check to the *input* image path too, not just the output path. A symlink inside the workspace pointing outside could otherwise be read and base64-encoded/forwarded upstream. - BaseTool.resolveIsOutsideWorkspace: wrap getState() in try/catch defaulting to false, so a provider torn down mid-operation no longer aborts the tool. - ReadFileTool.requestApproval: read provider state once and pass the flag down to each per-file boundary check in a batch, instead of a getState() per file. - pathUtils.isPathOutsideWorkspace: normalize case on macOS/Windows before comparing, so realpath casing differences don't cause a path inside the workspace to be reported as outside (false negative). +regression test.
1 parent 0b64f48 commit a0bb8b6

5 files changed

Lines changed: 80 additions & 8 deletions

File tree

src/core/tools/BaseTool.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,17 @@ export abstract class BaseTool<TName extends ToolName> {
114114
absolutePath: string,
115115
allowSymlinksOutsideWorkspace?: boolean,
116116
): Promise<boolean> {
117-
const allow =
118-
allowSymlinksOutsideWorkspace ??
119-
(await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ??
120-
false
117+
let allow = allowSymlinksOutsideWorkspace
118+
if (allow === undefined) {
119+
try {
120+
allow = (await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
121+
} catch {
122+
// The provider may have been torn down mid-operation, in which case
123+
// `getState()` rejects. Don't abort the tool call — default to the safe
124+
// (symlink-resolving, fail-closed) behavior instead.
125+
allow = false
126+
}
127+
}
121128
return isPathOutsideWorkspace(absolutePath, { allowSymlinksOutsideWorkspace: allow })
122129
}
123130

src/core/tools/GenerateImageTool.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ export class GenerateImageTool extends BaseTool<"generate_image"> {
8282
return
8383
}
8484

85+
// Apply the workspace-boundary check to the input image too (#169): a symlink
86+
// living inside the workspace but pointing outside it could otherwise be read
87+
// and base64-encoded/forwarded upstream, bypassing the boundary.
88+
const inputIsOutsideWorkspace = await this.resolveIsOutsideWorkspace(
89+
task,
90+
inputImageFullPath,
91+
state?.allowSymlinksOutsideWorkspace,
92+
)
93+
if (inputIsOutsideWorkspace) {
94+
const message = `Input image is outside the workspace: ${getReadablePath(task.cwd, inputImagePath)}`
95+
await task.say("error", message)
96+
task.didToolFailInCurrentTurn = true
97+
pushToolResult(formatResponse.toolError(message))
98+
return
99+
}
100+
85101
try {
86102
const imageBuffer = await fs.readFile(inputImageFullPath)
87103
const imageExtension = path.extname(inputImageFullPath).toLowerCase().replace(".", "")
@@ -162,7 +178,11 @@ export class GenerateImageTool extends BaseTool<"generate_image"> {
162178
}
163179

164180
const fullPath = path.resolve(task.cwd, relPath)
165-
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, fullPath)
181+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(
182+
task,
183+
fullPath,
184+
state?.allowSymlinksOutsideWorkspace,
185+
)
166186

167187
const sharedMessageProps = {
168188
tool: "generateImage" as const,

src/core/tools/ReadFileTool.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,13 +430,22 @@ export class ReadFileTool extends BaseTool<"read_file"> {
430430
): Promise<void> {
431431
if (filesToApprove.length === 0) return
432432

433+
// Read provider state once and pass the flag down, rather than firing a separate
434+
// `getState()` lookup per file in the batch below (mirrors ListFilesTool).
435+
let allowSymlinks = false
436+
try {
437+
allowSymlinks = (await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
438+
} catch {
439+
allowSymlinks = false
440+
}
441+
433442
if (filesToApprove.length > 1) {
434443
// Batch approval
435444
const batchFiles = await Promise.all(
436445
filesToApprove.map(async (fileResult) => {
437446
const relPath = fileResult.path
438447
const fullPath = path.resolve(task.cwd, relPath)
439-
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, fullPath)
448+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, fullPath, allowSymlinks)
440449
const readablePath = getReadablePath(task.cwd, relPath)
441450

442451
const lineSnippet = this.getLineSnippet(fileResult.entry!)
@@ -502,7 +511,7 @@ export class ReadFileTool extends BaseTool<"read_file"> {
502511
const fileResult = filesToApprove[0]
503512
const relPath = fileResult.path
504513
const fullPath = path.resolve(task.cwd, relPath)
505-
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, fullPath)
514+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, fullPath, allowSymlinks)
506515
const lineSnippet = this.getLineSnippet(fileResult.entry!)
507516

508517
const startLine = this.getStartLine(fileResult.entry!)

src/utils/__tests__/pathUtils.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,31 @@ describe("isPathOutsideWorkspace", () => {
165165
mockWorkspace.folders = []
166166
expect(isPathOutsideWorkspace(path.join(workspaceDir, "file.ts"))).toBe(true)
167167
})
168+
169+
it("normalizes case on case-insensitive platforms so a differently-cased inside path is still inside (#241)", () => {
170+
const originalPlatform = process.platform
171+
Object.defineProperty(process, "platform", { value: "darwin", configurable: true })
172+
173+
const inside = path.join(workspaceDir, "File.ts")
174+
fs.writeFileSync(inside, "x")
175+
176+
// On case-insensitive macOS/Windows, realpath can return a different case for the
177+
// resolved file than VS Code registered for the workspace folder. Simulate that by
178+
// upper-casing the "workspace" segment only for the target file's resolution.
179+
const realNative = fs.realpathSync.native
180+
const spy = vi.spyOn(fs.realpathSync, "native").mockImplementation(((p: string) => {
181+
const resolved = realNative(p)
182+
return p === inside
183+
? resolved.replace(`${path.sep}workspace${path.sep}`, `${path.sep}WORKSPACE${path.sep}`)
184+
: resolved
185+
}) as typeof fs.realpathSync.native)
186+
187+
try {
188+
// Without case normalization the case mismatch would wrongly report "outside".
189+
expect(isPathOutsideWorkspace(inside)).toBe(false)
190+
} finally {
191+
spy.mockRestore()
192+
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true })
193+
}
194+
})
168195
})

src/utils/pathUtils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ export function isPathOutsideWorkspace(
8282
return true
8383
}
8484

85+
// On case-insensitive filesystems (macOS APFS/HFS+, Windows) `realpath` may return a
86+
// different case than VS Code registered for the workspace folder, which would make a
87+
// path that is actually inside the workspace compare as "outside" (a false negative on
88+
// the security boundary). Normalize case before comparing on those platforms only.
89+
const caseInsensitive = process.platform === "darwin" || process.platform === "win32"
90+
const normalize = (p: string) => (caseInsensitive ? p.toLowerCase() : p)
91+
const target = normalize(absolutePath)
92+
8593
// Check if the path is within any workspace folder
8694
return !vscode.workspace.workspaceFolders.some((folder) => {
8795
// Resolve the workspace folder too, in case it is itself reached via a symlink.
@@ -93,6 +101,7 @@ export function isPathOutsideWorkspace(
93101
return false
94102
}
95103
// Path is inside a workspace if it equals the workspace path or is a subfolder
96-
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
104+
const base = normalize(folderPath)
105+
return target === base || target.startsWith(base + path.sep)
97106
})
98107
}

0 commit comments

Comments
 (0)