Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/symlink-workspace-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"zoo-code": patch
---

Resolve symlinks in the workspace-boundary check so a symlink inside the workspace that points outside is correctly treated as outside, closing a bypass of the out-of-workspace file protection (#169). Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) for users who deliberately rely on symlinks that point outside the workspace.
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const globalSettingsSchema = z.object({
alwaysAllowWrite: z.boolean().optional(),
alwaysAllowWriteOutsideWorkspace: z.boolean().optional(),
alwaysAllowWriteProtected: z.boolean().optional(),
allowSymlinksOutsideWorkspace: z.boolean().optional(),
writeDelayMs: z.number().min(0).optional(),
requestDelaySeconds: z.number().optional(),
alwaysAllowMcp: z.boolean().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export type ExtensionState = Pick<
| "alwaysAllowReadOnlyOutsideWorkspace"
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "allowSymlinksOutsideWorkspace"
| "alwaysAllowWriteProtected"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
Expand Down
13 changes: 13 additions & 0 deletions src/core/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ToolName } from "@roo-code/types"

import { Task } from "../task/Task"
import type { ToolUse, HandleError, PushToolResult, AskApproval, NativeToolArgs } from "../../shared/tools"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"

/**
* Callbacks passed to tool execution
Expand Down Expand Up @@ -98,6 +99,18 @@ export abstract class BaseTool<TName extends ToolName> {
this.lastSeenPartialPath = undefined
}

/**
* Resolve whether an absolute path is outside the workspace, honoring the
* `allowSymlinksOutsideWorkspace` setting (#169 / #241). When that setting is enabled,
* a symlink resolving outside the workspace is treated by its lexical path rather than
* being blocked; otherwise symlink targets are resolved and the check fails closed.
*/
protected async resolveIsOutsideWorkspace(task: Task, absolutePath: string): Promise<boolean> {
const allowSymlinksOutsideWorkspace =
(await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
return isPathOutsideWorkspace(absolutePath, { allowSymlinksOutsideWorkspace })
}

/**
* Main entry point for tool execution.
*
Expand Down
7 changes: 3 additions & 4 deletions src/core/tools/EditFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import path from "path"
import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"

import { getReadablePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
Expand Down Expand Up @@ -157,7 +156,7 @@ export class EditFileTool extends BaseTool<"edit_file"> {
}

const absolutePath = path.resolve(task.cwd, relPath)
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
Expand Down Expand Up @@ -399,7 +398,7 @@ export class EditFileTool extends BaseTool<"edit_file"> {

const sanitizedDiff = sanitizeUnifiedDiff(diff || "")
const diffStats = computeDiffStats(sanitizedDiff) || undefined
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const sharedMessageProps: ClineSayTool = {
tool: isNewFile ? "newFileCreated" : "appliedDiff",
Expand Down Expand Up @@ -512,7 +511,7 @@ export class EditFileTool extends BaseTool<"edit_file"> {
this.partialToolAskRelPath = relPath

const absolutePath = path.resolve(task.cwd, relPath)
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
Expand Down
5 changes: 2 additions & 3 deletions src/core/tools/ListFilesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { listFiles } from "../../services/glob/list-files"
import { getReadablePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import type { ToolUse } from "../../shared/tools"

import { BaseTool, ToolCallbacks } from "./BaseTool"
Expand Down Expand Up @@ -35,7 +34,7 @@ export class ListFilesTool extends BaseTool<"list_files"> {
task.consecutiveMistakeCount = 0

const absolutePath = path.resolve(task.cwd, relDirPath)
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const [files, didHitLimit] = await listFiles(absolutePath, recursive || false, 200)
const { showRooIgnoredFiles = false } = (await task.providerRef.deref()?.getState()) ?? {}
Expand Down Expand Up @@ -74,7 +73,7 @@ export class ListFilesTool extends BaseTool<"list_files"> {
const recursive = recursiveRaw?.toLowerCase() === "true"

const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const sharedMessageProps: ClineSayTool = {
tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive",
Expand Down
5 changes: 2 additions & 3 deletions src/core/tools/SearchReplaceTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import path from "path"
import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"

import { getReadablePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
Expand Down Expand Up @@ -170,7 +169,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {

const sanitizedDiff = sanitizeUnifiedDiff(diff)
const diffStats = computeDiffStats(sanitizedDiff) || undefined
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
Expand Down Expand Up @@ -262,7 +261,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
}

const absolutePath = path.resolve(task.cwd, relPath)
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
Expand Down
112 changes: 112 additions & 0 deletions src/utils/__tests__/pathUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as fs from "fs"
import * as os from "os"
import * as path from "path"

import { isPathOutsideWorkspace } from "../pathUtils"

// Mutable workspaceFolders the mocked vscode module reads from.
const { mockWorkspace } = vi.hoisted(() => ({
mockWorkspace: { folders: [] as Array<{ uri: { fsPath: string } }> },
}))

vi.mock("vscode", () => ({
workspace: {
get workspaceFolders() {
return mockWorkspace.folders.length > 0 ? mockWorkspace.folders : undefined
},
},
}))

describe("isPathOutsideWorkspace", () => {
let tmpRoot: string
let workspaceDir: string
let outsideDir: string

beforeEach(() => {
// realpath the tmp dir because macOS resolves /var -> /private/var
tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "zoo-pathutils-")))
workspaceDir = path.join(tmpRoot, "workspace")
outsideDir = path.join(tmpRoot, "outside")
fs.mkdirSync(workspaceDir)
fs.mkdirSync(outsideDir)
mockWorkspace.folders = [{ uri: { fsPath: workspaceDir } }]
})

afterEach(() => {
mockWorkspace.folders = []
fs.rmSync(tmpRoot, { recursive: true, force: true })
})

it("treats a real file inside the workspace as inside", () => {
const inside = path.join(workspaceDir, "file.ts")
fs.writeFileSync(inside, "x")
expect(isPathOutsideWorkspace(inside)).toBe(false)
})

it("treats a real file outside the workspace as outside", () => {
const outside = path.join(outsideDir, "secret.txt")
fs.writeFileSync(outside, "secret")
expect(isPathOutsideWorkspace(outside)).toBe(true)
})

it("treats a not-yet-existing file inside the workspace as inside", () => {
// File about to be created — realpath of the parent (workspace) still resolves.
expect(isPathOutsideWorkspace(path.join(workspaceDir, "new-file.ts"))).toBe(false)
})

it("treats a symlink inside the workspace that points outside as OUTSIDE (#169)", () => {
const secret = path.join(outsideDir, "secret.txt")
fs.writeFileSync(secret, "secret")
const link = path.join(workspaceDir, "link-to-secret.txt")
fs.symlinkSync(secret, link)

// Lexically the link lives inside the workspace, but it resolves outside.
expect(isPathOutsideWorkspace(link)).toBe(true)
})

it("treats a symlinked directory inside the workspace that points outside as OUTSIDE (#169)", () => {
fs.writeFileSync(path.join(outsideDir, "deep.txt"), "secret")
const linkDir = path.join(workspaceDir, "linked-dir")
fs.symlinkSync(outsideDir, linkDir)

expect(isPathOutsideWorkspace(path.join(linkDir, "deep.txt"))).toBe(true)
})

it("allows a symlink pointing outside when allowSymlinksOutsideWorkspace is enabled (#246)", () => {
const secret = path.join(outsideDir, "secret.txt")
fs.writeFileSync(secret, "secret")
const link = path.join(workspaceDir, "link-to-secret.txt")
fs.symlinkSync(secret, link)

// Default (secure, #169): the link resolves outside the workspace.
expect(isPathOutsideWorkspace(link)).toBe(true)
// Opt-in (#246): symlinks are not resolved, so the link's lexical location
// (inside the workspace) wins and it is treated as inside.
expect(isPathOutsideWorkspace(link, { allowSymlinksOutsideWorkspace: true })).toBe(false)
})

it("fails closed when symlink resolution throws a non-ENOENT error such as EACCES (#169)", () => {
const restricted = path.join(workspaceDir, "restricted.txt")
fs.writeFileSync(restricted, "x")

// Simulate realpath failing with EACCES (e.g. a symlink whose target has
// restricted permissions). The path lexically lives inside the workspace, but
// an unresolvable symlink must be treated as outside, not silently allowed.
const spy = vi.spyOn(fs.realpathSync, "native").mockImplementation(() => {
const err: NodeJS.ErrnoException = new Error("permission denied")
err.code = "EACCES"
throw err
})

try {
expect(isPathOutsideWorkspace(restricted)).toBe(true)
} finally {
spy.mockRestore()
}
})

it("returns true when there are no workspace folders", () => {
mockWorkspace.folders = []
expect(isPathOutsideWorkspace(path.join(workspaceDir, "file.ts"))).toBe(true)
})
})
82 changes: 78 additions & 4 deletions src/utils/pathUtils.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,97 @@
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs"

/** Narrow an unknown error to a Node errno exception with the given `code`. */
function isErrnoException(err: unknown, code: string): boolean {
return err instanceof Error && (err as NodeJS.ErrnoException).code === code
}

/**
* Resolves a path to its canonical form, following symlinks.
*
* If the path does not exist yet (e.g. a file that is about to be created), the
* realpath of the nearest existing ancestor is resolved and the remaining
* segments are re-appended. This ensures a symlink anywhere along the path is
* still followed, while paths that don't exist yet can still be evaluated.
*
* Only `ENOENT` (a not-yet-existing segment) triggers the walk-up. Any other
* error — e.g. `EACCES` on a symlink whose target has restricted permissions —
* is re-thrown rather than swallowed: silently walking up would mask the symlink
* and could let an out-of-workspace target look "inside". Callers performing a
* security check are expected to fail closed on a thrown error. See issue #169.
*/
function realPathOrNearest(target: string): string {
let current = path.resolve(target)
const trailing: string[] = []

// Walk up until an existing path can be resolved, bounded by the filesystem root.
while (true) {
try {
const resolved = fs.realpathSync.native(current)
return trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved
} catch (err) {
if (!isErrnoException(err, "ENOENT")) {
// Non-ENOENT (e.g. EACCES): don't mask it with a walk-up — propagate so the
// caller's security check can fail closed instead of falling through to the
// lexical path.
throw err
}
const parent = path.dirname(current)
if (parent === current) {
// Reached the root without finding an existing path; fall back to the
// lexically resolved path.
return path.resolve(target)
}
trailing.push(path.basename(current))
current = parent
}
}
}

/**
* Checks if a file path is outside all workspace folders
* @param filePath The file path to check
* @returns true if the path is outside all workspace folders, false otherwise
*/
export function isPathOutsideWorkspace(filePath: string): boolean {
export function isPathOutsideWorkspace(
filePath: string,
options: { allowSymlinksOutsideWorkspace?: boolean } = {},
): boolean {
// If there are no workspace folders, consider everything outside workspace for safety
if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
return true
}

// Normalize and resolve the path to handle .. and . components correctly
const absolutePath = path.resolve(filePath)
// By default we resolve symlinks (not just "." / "..") so a symlink that lives
// inside the workspace but points outside it is correctly treated as outside.
// Without this, the out-of-workspace read protection was trivially bypassed by
// symlinking to a file outside the workspace (#169).
//
// When the user opts in via `allowSymlinksOutsideWorkspace`, we compare lexical
// paths instead (path.resolve, no symlink resolution) — restoring the pre-#169
// behavior for those who deliberately rely on symlinks pointing outside.
const resolvePath = options.allowSymlinksOutsideWorkspace ? (p: string) => path.resolve(p) : realPathOrNearest

let absolutePath: string
try {
absolutePath = resolvePath(filePath)
} catch {
// Could not safely resolve the target (e.g. EACCES on a symlink). Fail closed:
// treat it as outside the workspace rather than risk a false "inside".
return true
}

// Check if the path is within any workspace folder
return !vscode.workspace.workspaceFolders.some((folder) => {
const folderPath = folder.uri.fsPath
// Resolve the workspace folder too, in case it is itself reached via a symlink.
let folderPath: string
try {
folderPath = resolvePath(folder.uri.fsPath)
} catch {
// Can't resolve this folder safely; it can't be used to prove containment.
return false
}
// Path is inside a workspace if it equals the workspace path or is a subfolder
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
maxWorkspaceFiles: number
showRooIgnoredFiles?: boolean
enableSubfolderRules?: boolean
allowSymlinksOutsideWorkspace?: boolean
maxImageFileSize?: number
maxTotalImageSize?: number
profileThresholds?: Record<string, number>
Expand All @@ -51,6 +52,7 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "maxWorkspaceFiles"
| "showRooIgnoredFiles"
| "enableSubfolderRules"
| "allowSymlinksOutsideWorkspace"
| "maxImageFileSize"
| "maxTotalImageSize"
| "profileThresholds"
Expand All @@ -71,6 +73,7 @@ export const ContextManagementSettings = ({
maxWorkspaceFiles,
showRooIgnoredFiles,
enableSubfolderRules,
allowSymlinksOutsideWorkspace,
setCachedStateField,
maxImageFileSize,
maxTotalImageSize,
Expand Down Expand Up @@ -246,6 +249,23 @@ export const ContextManagementSettings = ({
</div>
</SearchableSetting>

<SearchableSetting
settingId="context-allow-symlinks-outside-workspace"
section="contextManagement"
label={t("settings:contextManagement.allowSymlinksOutsideWorkspace.label")}>
<VSCodeCheckbox
checked={allowSymlinksOutsideWorkspace}
onChange={(e: any) => setCachedStateField("allowSymlinksOutsideWorkspace", e.target.checked)}
data-testid="allow-symlinks-outside-workspace-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.allowSymlinksOutsideWorkspace.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.allowSymlinksOutsideWorkspace.description")}
</div>
</SearchableSetting>

<SearchableSetting
settingId="context-max-image-file-size"
section="contextManagement"
Expand Down
Loading
Loading