Skip to content

Commit 26fbe15

Browse files
feat: add allowSymlinksOutsideWorkspace opt-in setting — backend (Zoo-Code-Org#169)
Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
1 parent 49a70d9 commit 26fbe15

8 files changed

Lines changed: 52 additions & 17 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export const globalSettingsSchema = z.object({
101101
alwaysAllowWrite: z.boolean().optional(),
102102
alwaysAllowWriteOutsideWorkspace: z.boolean().optional(),
103103
alwaysAllowWriteProtected: z.boolean().optional(),
104+
allowSymlinksOutsideWorkspace: z.boolean().optional(),
104105
writeDelayMs: z.number().min(0).optional(),
105106
requestDelaySeconds: z.number().optional(),
106107
alwaysAllowMcp: z.boolean().optional(),
@@ -329,6 +330,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
329330
alwaysAllowReadOnlyOutsideWorkspace: false,
330331
alwaysAllowWrite: true,
331332
alwaysAllowWriteOutsideWorkspace: false,
333+
allowSymlinksOutsideWorkspace: false,
332334
alwaysAllowWriteProtected: false,
333335
writeDelayMs: 1000,
334336
requestDelaySeconds: 10,

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export type ExtensionState = Pick<
257257
| "alwaysAllowReadOnlyOutsideWorkspace"
258258
| "alwaysAllowWrite"
259259
| "alwaysAllowWriteOutsideWorkspace"
260+
| "allowSymlinksOutsideWorkspace"
260261
| "alwaysAllowWriteProtected"
261262
| "alwaysAllowMcp"
262263
| "alwaysAllowModeSwitch"

src/core/tools/BaseTool.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ToolName } from "@roo-code/types"
22

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

67
/**
78
* Callbacks passed to tool execution
@@ -98,6 +99,18 @@ export abstract class BaseTool<TName extends ToolName> {
9899
this.lastSeenPartialPath = undefined
99100
}
100101

102+
/**
103+
* Resolve whether an absolute path is outside the workspace, honoring the
104+
* `allowSymlinksOutsideWorkspace` setting (#169 / #241). When that setting is enabled,
105+
* a symlink resolving outside the workspace is treated by its lexical path rather than
106+
* being blocked; otherwise symlink targets are resolved and the check fails closed.
107+
*/
108+
protected async resolveIsOutsideWorkspace(task: Task, absolutePath: string): Promise<boolean> {
109+
const allowSymlinksOutsideWorkspace =
110+
(await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
111+
return isPathOutsideWorkspace(absolutePath, { allowSymlinksOutsideWorkspace })
112+
}
113+
101114
/**
102115
* Main entry point for tool execution.
103116
*

src/core/tools/EditFileTool.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import path from "path"
44
import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
55

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

159158
const absolutePath = path.resolve(task.cwd, relPath)
160-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
159+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
161160

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

400399
const sanitizedDiff = sanitizeUnifiedDiff(diff || "")
401400
const diffStats = computeDiffStats(sanitizedDiff) || undefined
402-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
401+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
403402

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

514513
const absolutePath = path.resolve(task.cwd, relPath)
515-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
514+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
516515

517516
const sharedMessageProps: ClineSayTool = {
518517
tool: "appliedDiff",

src/core/tools/ListFilesTool.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { Task } from "../task/Task"
66
import { formatResponse } from "../prompts/responses"
77
import { listFiles } from "../../services/glob/list-files"
88
import { getReadablePath } from "../../utils/path"
9-
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
109
import type { ToolUse } from "../../shared/tools"
1110

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

3736
const absolutePath = path.resolve(task.cwd, relDirPath)
38-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
37+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
3938

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

7675
const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd
77-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
76+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
7877

7978
const sharedMessageProps: ClineSayTool = {
8079
tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive",

src/core/tools/SearchReplaceTool.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import path from "path"
44
import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
55

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

171170
const sanitizedDiff = sanitizeUnifiedDiff(diff)
172171
const diffStats = computeDiffStats(sanitizedDiff) || undefined
173-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
172+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
174173

175174
const sharedMessageProps: ClineSayTool = {
176175
tool: "appliedDiff",
@@ -262,7 +261,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
262261
}
263262

264263
const absolutePath = path.resolve(task.cwd, relPath)
265-
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
264+
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)
266265

267266
const sharedMessageProps: ClineSayTool = {
268267
tool: "appliedDiff",

src/utils/__tests__/pathUtils.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ describe("isPathOutsideWorkspace", () => {
7272
expect(isPathOutsideWorkspace(path.join(linkDir, "deep.txt"))).toBe(true)
7373
})
7474

75+
it("allows a symlink pointing outside when allowSymlinksOutsideWorkspace is enabled (#246)", () => {
76+
const secret = path.join(outsideDir, "secret.txt")
77+
fs.writeFileSync(secret, "secret")
78+
const link = path.join(workspaceDir, "link-to-secret.txt")
79+
fs.symlinkSync(secret, link)
80+
81+
// Default (secure, #169): the link resolves outside the workspace.
82+
expect(isPathOutsideWorkspace(link)).toBe(true)
83+
// Opt-in (#246): symlinks are not resolved, so the link's lexical location
84+
// (inside the workspace) wins and it is treated as inside.
85+
expect(isPathOutsideWorkspace(link, { allowSymlinksOutsideWorkspace: true })).toBe(false)
86+
})
87+
7588
it("fails closed when symlink resolution throws a non-ENOENT error such as EACCES (#169)", () => {
7689
const restricted = path.join(workspaceDir, "restricted.txt")
7790
fs.writeFileSync(restricted, "x")

src/utils/pathUtils.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,28 @@ function realPathOrNearest(target: string): string {
5454
* @param filePath The file path to check
5555
* @returns true if the path is outside all workspace folders, false otherwise
5656
*/
57-
export function isPathOutsideWorkspace(filePath: string): boolean {
57+
export function isPathOutsideWorkspace(
58+
filePath: string,
59+
options: { allowSymlinksOutsideWorkspace?: boolean } = {},
60+
): boolean {
5861
// If there are no workspace folders, consider everything outside workspace for safety
5962
if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
6063
return true
6164
}
6265

63-
// Resolve symlinks (not just "." / "..") so a symlink that lives inside the
64-
// workspace but points outside it is correctly treated as outside. Without
65-
// this, the out-of-workspace read protection was trivially bypassed by
66-
// symlinking to a file outside the workspace. See issue #169.
66+
// By default we resolve symlinks (not just "." / "..") so a symlink that lives
67+
// inside the workspace but points outside it is correctly treated as outside.
68+
// Without this, the out-of-workspace read protection was trivially bypassed by
69+
// symlinking to a file outside the workspace (#169).
70+
//
71+
// When the user opts in via `allowSymlinksOutsideWorkspace`, we compare lexical
72+
// paths instead (path.resolve, no symlink resolution) — restoring the pre-#169
73+
// behavior for those who deliberately rely on symlinks pointing outside.
74+
const resolvePath = options.allowSymlinksOutsideWorkspace ? (p: string) => path.resolve(p) : realPathOrNearest
75+
6776
let absolutePath: string
6877
try {
69-
absolutePath = realPathOrNearest(filePath)
78+
absolutePath = resolvePath(filePath)
7079
} catch {
7180
// Could not safely resolve the target (e.g. EACCES on a symlink). Fail closed:
7281
// treat it as outside the workspace rather than risk a false "inside".
@@ -78,7 +87,7 @@ export function isPathOutsideWorkspace(filePath: string): boolean {
7887
// Resolve the workspace folder too, in case it is itself reached via a symlink.
7988
let folderPath: string
8089
try {
81-
folderPath = realPathOrNearest(folder.uri.fsPath)
90+
folderPath = resolvePath(folder.uri.fsPath)
8291
} catch {
8392
// Can't resolve this folder safely; it can't be used to prove containment.
8493
return false

0 commit comments

Comments
 (0)