From 4903f3ab94a0ae70dea89d0826e33cecba7ad07c Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 16 Oct 2025 15:56:31 -0700 Subject: [PATCH 1/3] add tool call policy evaluation --- core/core.ts | 18 +- core/tools/definitions/ls.ts | 27 ++- core/tools/definitions/readFile.ts | 24 +- core/tools/definitions/readFileRange.ts | 22 ++ core/tools/definitions/viewSubdirectory.ts | 22 ++ core/tools/implementations/lsTool.ts | 17 +- core/tools/implementations/readFile.ts | 24 +- core/tools/implementations/readFileRange.ts | 23 +- .../tools/implementations/viewSubdirectory.ts | 14 +- core/tools/policies/fileAccess.ts | 26 +++ core/util/pathResolver.test.ts | 205 ++++++++++++++++++ core/util/pathResolver.ts | 134 ++++++++++++ extensions/cli/src/util/pathResolver.ts | 33 +++ 13 files changed, 551 insertions(+), 38 deletions(-) create mode 100644 core/tools/policies/fileAccess.ts create mode 100644 core/util/pathResolver.test.ts create mode 100644 core/util/pathResolver.ts create mode 100644 extensions/cli/src/util/pathResolver.ts diff --git a/core/core.ts b/core/core.ts index 87512ccf50c..ae4c352cacc 100644 --- a/core/core.ts +++ b/core/core.ts @@ -1102,14 +1102,26 @@ export class Core { return { policy: basePolicy }; } + // Preprocess args if preprocessor exists + let processedArgs = args; + if (tool.preprocessArgs) { + try { + processedArgs = await tool.preprocessArgs(args, { ide: this.ide }); + } catch (e) { + // If preprocessing fails, use original args + console.warn(`Failed to preprocess args for ${toolName}:`, e); + processedArgs = args; + } + } + // Extract display value for specific tools let displayValue: string | undefined; - if (toolName === "runTerminalCommand" && args.command) { - displayValue = args.command as string; + if (toolName === "runTerminalCommand" && processedArgs.command) { + displayValue = processedArgs.command as string; } if (tool.evaluateToolCallPolicy) { - const evaluatedPolicy = tool.evaluateToolCallPolicy(basePolicy, args); + const evaluatedPolicy = tool.evaluateToolCallPolicy(basePolicy, processedArgs); return { policy: evaluatedPolicy, displayValue }; } return { policy: basePolicy, displayValue }; diff --git a/core/tools/definitions/ls.ts b/core/tools/definitions/ls.ts index 49ac5b1c6a1..89b850fe2db 100644 --- a/core/tools/definitions/ls.ts +++ b/core/tools/definitions/ls.ts @@ -1,6 +1,9 @@ import { Tool } from "../.."; +import { ToolPolicy } from "@continuedev/terminal-security"; +import { resolveInputPath } from "../../util/pathResolver"; import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; +import { evaluateFileAccessPolicy } from "../policies/fileAccess"; export const lsTool: Tool = { type: "function", @@ -20,7 +23,7 @@ export const lsTool: Tool = { dirPath: { type: "string", description: - "The directory path relative to the root of the project. Use forward slash paths like '/'. rather than e.g. '.'", + "The directory path. Can be relative to project root, absolute path, tilde path (~/...), or file:// URI. Use forward slash paths", }, recursive: { type: "boolean", @@ -39,4 +42,26 @@ export const lsTool: Tool = { ], }, toolCallIcon: "FolderIcon", + preprocessArgs: async (args, { ide }) => { + const dirPath = args.dirPath as string; + + // Default to current directory if no path provided + const pathToResolve = dirPath || "."; + const resolvedPath = await resolveInputPath(ide, pathToResolve); + + // Store the resolved path info in args for policy evaluation + return { + ...args, + _resolvedPath: resolvedPath, + }; + }, + evaluateToolCallPolicy: ( + basePolicy: ToolPolicy, + parsedArgs: Record, + ): ToolPolicy => { + const resolvedPath = parsedArgs._resolvedPath as any; + if (!resolvedPath) return basePolicy; + + return evaluateFileAccessPolicy(basePolicy, resolvedPath.isWithinWorkspace); + }, }; diff --git a/core/tools/definitions/readFile.ts b/core/tools/definitions/readFile.ts index ee6c1c91cfb..b81ab3cf2a2 100644 --- a/core/tools/definitions/readFile.ts +++ b/core/tools/definitions/readFile.ts @@ -1,5 +1,8 @@ +import { ToolPolicy } from "@continuedev/terminal-security"; import { Tool } from "../.."; +import { resolveInputPath } from "../../util/pathResolver"; import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; +import { evaluateFileAccessPolicy } from "../policies/fileAccess"; export const readFileTool: Tool = { type: "function", @@ -21,7 +24,7 @@ export const readFileTool: Tool = { filepath: { type: "string", description: - "The path of the file to read, relative to the root of the workspace (NOT uri or absolute path)", + "The path of the file to read. Can be a relative path (from workspace root), absolute path, tilde path (~/...), or file:// URI", }, }, }, @@ -32,4 +35,23 @@ export const readFileTool: Tool = { }, defaultToolPolicy: "allowedWithoutPermission", toolCallIcon: "DocumentIcon", + preprocessArgs: async (args, { ide }) => { + const filepath = args.filepath as string; + const resolvedPath = await resolveInputPath(ide, filepath); + + // Store the resolved path info in args for policy evaluation + return { + ...args, + _resolvedPath: resolvedPath, + }; + }, + evaluateToolCallPolicy: ( + basePolicy: ToolPolicy, + parsedArgs: Record, + ): ToolPolicy => { + const resolvedPath = parsedArgs._resolvedPath as any; + if (!resolvedPath) return basePolicy; + + return evaluateFileAccessPolicy(basePolicy, resolvedPath.isWithinWorkspace); + }, }; diff --git a/core/tools/definitions/readFileRange.ts b/core/tools/definitions/readFileRange.ts index 41a7bfcfd6b..04599581c7c 100644 --- a/core/tools/definitions/readFileRange.ts +++ b/core/tools/definitions/readFileRange.ts @@ -1,5 +1,8 @@ +import { ToolPolicy } from "@continuedev/terminal-security"; import { Tool } from "../.."; +import { resolveInputPath } from "../../util/pathResolver"; import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; +import { evaluateFileAccessPolicy } from "../policies/fileAccess"; export const readFileRangeTool: Tool = { type: "function", @@ -49,4 +52,23 @@ export const readFileRangeTool: Tool = { }, defaultToolPolicy: "allowedWithoutPermission", toolCallIcon: "DocumentIcon", + preprocessArgs: async (args, { ide }) => { + const filepath = args.filepath as string; + const resolvedPath = await resolveInputPath(ide, filepath); + + // Store the resolved path info in args for policy evaluation + return { + ...args, + _resolvedPath: resolvedPath, + }; + }, + evaluateToolCallPolicy: ( + basePolicy: ToolPolicy, + parsedArgs: Record, + ): ToolPolicy => { + const resolvedPath = parsedArgs._resolvedPath as any; + if (!resolvedPath) return basePolicy; + + return evaluateFileAccessPolicy(basePolicy, resolvedPath.isWithinWorkspace); + }, }; diff --git a/core/tools/definitions/viewSubdirectory.ts b/core/tools/definitions/viewSubdirectory.ts index f8776716f52..892b11835d8 100644 --- a/core/tools/definitions/viewSubdirectory.ts +++ b/core/tools/definitions/viewSubdirectory.ts @@ -1,5 +1,8 @@ +import { ToolPolicy } from "@continuedev/terminal-security"; import { Tool } from "../.."; +import { resolveInputPath } from "../../util/pathResolver"; import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; +import { evaluateFileAccessPolicy } from "../policies/fileAccess"; export const viewSubdirectoryTool: Tool = { type: "function", @@ -31,4 +34,23 @@ export const viewSubdirectoryTool: Tool = { }, defaultToolPolicy: "allowedWithPermission", toolCallIcon: "FolderOpenIcon", + preprocessArgs: async (args, { ide }) => { + const directoryPath = args.directory_path as string; + const resolvedPath = await resolveInputPath(ide, directoryPath); + + // Store the resolved path info in args for policy evaluation + return { + ...args, + _resolvedPath: resolvedPath, + }; + }, + evaluateToolCallPolicy: ( + basePolicy: ToolPolicy, + parsedArgs: Record, + ): ToolPolicy => { + const resolvedPath = parsedArgs._resolvedPath as any; + if (!resolvedPath) return basePolicy; + + return evaluateFileAccessPolicy(basePolicy, resolvedPath.isWithinWorkspace); + }, }; diff --git a/core/tools/implementations/lsTool.ts b/core/tools/implementations/lsTool.ts index 67122f082be..a02f2806562 100644 --- a/core/tools/implementations/lsTool.ts +++ b/core/tools/implementations/lsTool.ts @@ -2,13 +2,14 @@ import ignore from "ignore"; import { ToolImpl } from "."; import { walkDir } from "../../indexing/walkDir"; -import { resolveRelativePathInDir } from "../../util/ideUtils"; +import { resolveInputPath } from "../../util/pathResolver"; export function resolveLsToolDirPath(dirPath: string | undefined) { if (!dirPath || dirPath === ".") { return "/"; } - if (dirPath.startsWith(".")) { + // Don't strip leading slash from absolute paths - let the resolver handle it + if (dirPath.startsWith(".") && !dirPath.startsWith("./")) { return dirPath.slice(1); } return dirPath.replace(/\\/g, "/"); @@ -18,14 +19,14 @@ const MAX_LS_TOOL_LINES = 200; export const lsToolImpl: ToolImpl = async (args, extras) => { const dirPath = resolveLsToolDirPath(args?.dirPath); - const uri = await resolveRelativePathInDir(dirPath, extras.ide); - if (!uri) { + const resolvedPath = await resolveInputPath(extras.ide, dirPath); + if (!resolvedPath) { throw new Error( - `Directory ${args.dirPath} not found. Make sure to use forward-slash paths`, + `Directory ${args.dirPath} not found or is not accessible. You can use absolute paths, relative paths, or paths starting with ~`, ); } - const entries = await walkDir(uri, extras.ide, { + const entries = await walkDir(resolvedPath.uri, extras.ide, { returnRelativeUrisPaths: true, include: "both", recursive: args?.recursive ?? false, @@ -37,12 +38,12 @@ export const lsToolImpl: ToolImpl = async (args, extras) => { let content = lines.length > 0 ? lines.join("\n") - : `No files/folders found in ${dirPath}`; + : `No files/folders found in ${resolvedPath.displayPath}`; const contextItems = [ { name: "File/folder list", - description: `Files/folders in ${dirPath}`, + description: `Files/folders in ${resolvedPath.displayPath}`, content, }, ]; diff --git a/core/tools/implementations/readFile.ts b/core/tools/implementations/readFile.ts index afa6495ad08..67bf168d03b 100644 --- a/core/tools/implementations/readFile.ts +++ b/core/tools/implementations/readFile.ts @@ -1,4 +1,4 @@ -import { resolveRelativePathInDir } from "../../util/ideUtils"; +import { resolveInputPath } from "../../util/pathResolver"; import { getUriPathBasename } from "../../util/uri"; import { ToolImpl } from "."; @@ -8,30 +8,34 @@ import { throwIfFileExceedsHalfOfContext } from "./readFileLimit"; export const readFileImpl: ToolImpl = async (args, extras) => { const filepath = getStringArg(args, "filepath"); - throwIfFileIsSecurityConcern(filepath); - const firstUriMatch = await resolveRelativePathInDir(filepath, extras.ide); - if (!firstUriMatch) { + // Resolve the path first to get the actual path for security check + const resolvedPath = await resolveInputPath(extras.ide, filepath); + if (!resolvedPath) { throw new Error( - `File "${filepath}" does not exist. You might want to check the path and try again.`, + `File "${filepath}" does not exist or is not accessible. You might want to check the path and try again.`, ); } - const content = await extras.ide.readFile(firstUriMatch); + + // Security check on the resolved display path + throwIfFileIsSecurityConcern(resolvedPath.displayPath); + + const content = await extras.ide.readFile(resolvedPath.uri); await throwIfFileExceedsHalfOfContext( - filepath, + resolvedPath.displayPath, content, extras.config.selectedModelByRole.chat, ); return [ { - name: getUriPathBasename(firstUriMatch), - description: filepath, + name: getUriPathBasename(resolvedPath.uri), + description: resolvedPath.displayPath, content, uri: { type: "file", - value: firstUriMatch, + value: resolvedPath.uri, }, }, ]; diff --git a/core/tools/implementations/readFileRange.ts b/core/tools/implementations/readFileRange.ts index c313f4bff14..4c274e8c731 100644 --- a/core/tools/implementations/readFileRange.ts +++ b/core/tools/implementations/readFileRange.ts @@ -1,7 +1,8 @@ -import { resolveRelativePathInDir } from "../../util/ideUtils"; +import { resolveInputPath } from "../../util/pathResolver"; import { getUriPathBasename } from "../../util/uri"; import { ToolImpl } from "."; +import { throwIfFileIsSecurityConcern } from "../../indexing/ignore"; import { getNumberArg, getStringArg } from "../parseArgs"; import { throwIfFileExceedsHalfOfContext } from "./readFileLimit"; @@ -27,15 +28,19 @@ export const readFileRangeImpl: ToolImpl = async (args, extras) => { ); } - const firstUriMatch = await resolveRelativePathInDir(filepath, extras.ide); - if (!firstUriMatch) { + // Resolve the path first to get the actual path for security check + const resolvedPath = await resolveInputPath(extras.ide, filepath); + if (!resolvedPath) { throw new Error( - `File "${filepath}" does not exist. You might want to check the path and try again.`, + `File "${filepath}" does not exist or is not accessible. You might want to check the path and try again.`, ); } + // Security check on the resolved display path + throwIfFileIsSecurityConcern(resolvedPath.displayPath); + // Use the IDE's readRangeInFile method with 0-based range (IDE expects 0-based internally) - const content = await extras.ide.readRangeInFile(firstUriMatch, { + const content = await extras.ide.readRangeInFile(resolvedPath.uri, { start: { line: startLine - 1, // Convert from 1-based to 0-based character: 0, @@ -47,21 +52,21 @@ export const readFileRangeImpl: ToolImpl = async (args, extras) => { }); await throwIfFileExceedsHalfOfContext( - filepath, + resolvedPath.displayPath, content, extras.config.selectedModelByRole.chat, ); - const rangeDescription = `${filepath} (lines ${startLine}-${endLine})`; + const rangeDescription = `${resolvedPath.displayPath} (lines ${startLine}-${endLine})`; return [ { - name: getUriPathBasename(firstUriMatch), + name: getUriPathBasename(resolvedPath.uri), description: rangeDescription, content, uri: { type: "file", - value: firstUriMatch, + value: resolvedPath.uri, }, }, ]; diff --git a/core/tools/implementations/viewSubdirectory.ts b/core/tools/implementations/viewSubdirectory.ts index b7897aae8a4..be421f5c694 100644 --- a/core/tools/implementations/viewSubdirectory.ts +++ b/core/tools/implementations/viewSubdirectory.ts @@ -1,5 +1,5 @@ import generateRepoMap from "../../util/generateRepoMap"; -import { resolveRelativePathInDir } from "../../util/ideUtils"; +import { resolveInputPath } from "../../util/pathResolver"; import { ToolImpl } from "."; import { getStringArg } from "../parseArgs"; @@ -7,14 +7,16 @@ import { getStringArg } from "../parseArgs"; export const viewSubdirectoryImpl: ToolImpl = async (args: any, extras) => { const directory_path = getStringArg(args, "directory_path"); - const uri = await resolveRelativePathInDir(directory_path, extras.ide); + const resolvedPath = await resolveInputPath(extras.ide, directory_path); - if (!uri) { - throw new Error(`Directory path "${directory_path}" does not exist.`); + if (!resolvedPath) { + throw new Error( + `Directory path "${directory_path}" does not exist or is not accessible.`, + ); } const repoMap = await generateRepoMap(extras.llm, extras.ide, { - dirUris: [uri], + dirUris: [resolvedPath.uri], outputRelativeUriPaths: true, includeSignatures: false, }); @@ -22,7 +24,7 @@ export const viewSubdirectoryImpl: ToolImpl = async (args: any, extras) => { return [ { name: "Repo map", - description: `Map of ${directory_path}`, + description: `Map of ${resolvedPath.displayPath}`, content: repoMap, }, ]; diff --git a/core/tools/policies/fileAccess.ts b/core/tools/policies/fileAccess.ts new file mode 100644 index 00000000000..1f4054a8124 --- /dev/null +++ b/core/tools/policies/fileAccess.ts @@ -0,0 +1,26 @@ +import { ToolPolicy } from "@continuedev/terminal-security"; + +/** + * Evaluates file access policy based on whether the file is within workspace boundaries + * + * @param basePolicy - The base policy from tool definition or user settings + * @param isWithinWorkspace - Whether the file/directory is within workspace + * @returns The evaluated policy - more restrictive for files outside workspace + */ +export function evaluateFileAccessPolicy( + basePolicy: ToolPolicy, + isWithinWorkspace: boolean +): ToolPolicy { + // If tool is disabled, keep it disabled + if (basePolicy === "disabled") { + return "disabled"; + } + + // Files within workspace use the base policy (typically "allowedWithoutPermission") + if (isWithinWorkspace) { + return basePolicy; + } + + // Files outside workspace always require permission for security + return "allowedWithPermission"; +} \ No newline at end of file diff --git a/core/util/pathResolver.test.ts b/core/util/pathResolver.test.ts new file mode 100644 index 00000000000..5df99609719 --- /dev/null +++ b/core/util/pathResolver.test.ts @@ -0,0 +1,205 @@ +import * as os from "os"; +import * as path from "path"; +import { IDE } from ".."; +import { normalizeDisplayPath, resolveInputPath } from "./pathResolver"; +import * as ideUtils from "./ideUtils"; + +// Mock the resolveRelativePathInDir function +jest.mock("./ideUtils"); + +describe("resolveUserProvidedPath", () => { + const mockIde = { + getWorkspaceDirs: jest.fn().mockResolvedValue(["/workspace"]), + fileExists: jest.fn(), + } as unknown as IDE; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup the mock for resolveRelativePathInDir + (ideUtils.resolveRelativePathInDir as jest.Mock).mockImplementation( + async (path, ide) => { + const workspaceUri = "file:///workspace"; + // Check if the file exists in workspace + const fullPath = `/workspace/${path}`; + const exists = await ide.fileExists(fullPath); + if (exists) { + return `${workspaceUri}/${path}`; + } + return null; + } + ); + }); + + describe("file:// URIs", () => { + it("should handle file:// URIs", async () => { + const result = await resolveInputPath( + mockIde, + "file:///path/to/file.txt", + ); + expect(result).toEqual({ + uri: "file:///path/to/file.txt", + displayPath: "/path/to/file.txt", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should handle file:// URIs with encoded spaces", async () => { + const result = await resolveInputPath( + mockIde, + "file:///path%20with%20spaces/file.txt", + ); + expect(result).toEqual({ + uri: "file:///path%20with%20spaces/file.txt", + displayPath: "/path with spaces/file.txt", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should detect workspace files via file:// URI", async () => { + const result = await resolveInputPath( + mockIde, + "file:///workspace/src/file.txt", + ); + expect(result).toEqual({ + uri: "file:///workspace/src/file.txt", + displayPath: "/workspace/src/file.txt", + isAbsolute: true, + isWithinWorkspace: true, + }); + }); + }); + + describe("tilde paths", () => { + it("should expand ~/path to home directory", async () => { + const homedir = os.homedir(); + const result = await resolveInputPath(mockIde, "~/Documents/file.txt"); + expect(result).toEqual({ + uri: `file://${path.join(homedir, "Documents", "file.txt")}`, + displayPath: path.join(homedir, "Documents", "file.txt"), + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should expand ~ alone to home directory", async () => { + const homedir = os.homedir(); + const result = await resolveInputPath(mockIde, "~"); + expect(result).toEqual({ + uri: `file://${homedir}`, + displayPath: homedir, + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should return null for ~username format", async () => { + const result = await resolveInputPath(mockIde, "~otheruser/file.txt"); + expect(result).toBeNull(); + }); + }); + + describe("absolute paths", () => { + it("should handle Unix absolute paths", async () => { + const result = await resolveInputPath(mockIde, "/usr/local/bin/file"); + expect(result).toEqual({ + uri: "file:///usr/local/bin/file", + displayPath: "/usr/local/bin/file", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should detect workspace absolute paths", async () => { + const result = await resolveInputPath(mockIde, "/workspace/src/file.txt"); + expect(result).toEqual({ + uri: "file:///workspace/src/file.txt", + displayPath: "/workspace/src/file.txt", + isAbsolute: true, + isWithinWorkspace: true, + }); + }); + + // Skip Windows-specific tests on non-Windows platforms + it.skip("should handle Windows drive letters", async () => { + const result = await resolveInputPath(mockIde, "C:\\Users\\file.txt"); + expect(result).toEqual({ + uri: "file:///C:/Users/file.txt", + displayPath: "C:\\Users\\file.txt", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should handle Windows network paths", async () => { + const result = await resolveInputPath( + mockIde, + "\\\\server\\share\\file.txt", + ); + expect(result).toEqual({ + uri: "file://server/share/file.txt", + displayPath: "\\\\server\\share\\file.txt", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + }); + + describe("relative paths", () => { + it("should resolve relative paths in workspace", async () => { + mockIde.fileExists = jest.fn().mockResolvedValue(true); + const result = await resolveInputPath(mockIde, "src/index.ts"); + expect(result).toEqual({ + uri: "file:///workspace/src/index.ts", + displayPath: "src/index.ts", + isAbsolute: false, + isWithinWorkspace: true, + }); + }); + + it("should return null for non-existent relative paths", async () => { + mockIde.fileExists = jest.fn().mockResolvedValue(false); + const result = await resolveInputPath(mockIde, "does/not/exist.txt"); + expect(result).toBeNull(); + }); + }); + + describe("edge cases", () => { + it("should trim whitespace from input", async () => { + const result = await resolveInputPath(mockIde, " /path/to/file.txt "); + expect(result).toEqual({ + uri: "file:///path/to/file.txt", + displayPath: "/path/to/file.txt", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + + it("should handle paths with spaces", async () => { + const result = await resolveInputPath( + mockIde, + "/path with spaces/file.txt", + ); + expect(result).toEqual({ + uri: "file:///path%20with%20spaces/file.txt", + displayPath: "/path with spaces/file.txt", + isAbsolute: true, + isWithinWorkspace: false, + }); + }); + }); +}); + +describe("normalizeDisplayPath", () => { + it("should contract home directory to ~", () => { + const homedir = os.homedir(); + + expect( + normalizeDisplayPath(path.join(homedir, "Documents", "file.txt")), + ).toBe("~/Documents/file.txt"); + + expect(normalizeDisplayPath("/usr/local/bin")).toBe("/usr/local/bin"); + }); +}); diff --git a/core/util/pathResolver.ts b/core/util/pathResolver.ts new file mode 100644 index 00000000000..a7d8baac248 --- /dev/null +++ b/core/util/pathResolver.ts @@ -0,0 +1,134 @@ +import * as os from "os"; +import * as path from "path"; +import { IDE } from ".."; +import { resolveRelativePathInDir } from "./ideUtils"; +import { localPathToUri } from "./pathToUri"; + +export interface ResolvedPath { + uri: string; + displayPath: string; + isAbsolute: boolean; + isWithinWorkspace: boolean; +} + +/** + * Checks if a path is within any of the workspace directories + */ +async function isPathWithinWorkspace( + ide: IDE, + absolutePath: string +): Promise { + const workspaceDirs = await ide.getWorkspaceDirs(); + const normalizedPath = path.normalize(absolutePath).toLowerCase(); + + for (const dir of workspaceDirs) { + const normalizedDir = path.normalize(dir).toLowerCase(); + if (normalizedPath.startsWith(normalizedDir)) { + return true; + } + } + + return false; +} + +/** + * Resolves user-provided paths that may be: + * - Relative to workspace directories + * - Absolute paths (Unix/Windows) + * - Tilde paths (~/ or ~username/) + * - File URIs (file://) + * + * Returns both the URI and a normalized display path. + */ +export async function resolveInputPath( + ide: IDE, + inputPath: string, +): Promise { + // Trim whitespace + const trimmedPath = inputPath.trim(); + + // Check for file:// URI + if (trimmedPath.startsWith("file://")) { + const uri = trimmedPath; + // Extract path from URI for display + const displayPath = decodeURIComponent(uri.slice(7)); + const isWithinWorkspace = await isPathWithinWorkspace(ide, displayPath); + return { + uri, + displayPath, + isAbsolute: true, + isWithinWorkspace, + }; + } + + // Expand tilde paths + let expandedPath = trimmedPath; + if (trimmedPath.startsWith("~/")) { + expandedPath = path.join(os.homedir(), trimmedPath.slice(2)); + } else if (trimmedPath === "~") { + expandedPath = os.homedir(); + } else if (trimmedPath.startsWith("~") && trimmedPath.includes("/")) { + // Handle ~username/ format (Unix-like systems) + // For now, we'll just return null as this requires more complex parsing + // and platform-specific handling + return null; + } + + // Check if it's an absolute path + const isAbsolute = + path.isAbsolute(expandedPath) || + // Windows network paths + expandedPath.startsWith("\\\\") || + // Windows drive letters (C:, D:, etc.) + /^[a-zA-Z]:/.test(expandedPath); + + if (isAbsolute) { + // For Windows network paths, handle specially + if (expandedPath.startsWith("\\\\")) { + const networkPath = expandedPath.replace(/\\/g, "/"); + const uri = "file:" + networkPath; // file://server/share format + const isWithinWorkspace = await isPathWithinWorkspace(ide, expandedPath); + return { + uri, + displayPath: expandedPath, + isAbsolute: true, + isWithinWorkspace, + }; + } + // Convert absolute path to URI + const uri = localPathToUri(expandedPath); + const isWithinWorkspace = await isPathWithinWorkspace(ide, expandedPath); + return { + uri, + displayPath: expandedPath, + isAbsolute: true, + isWithinWorkspace, + }; + } + + // Fall back to relative path resolution within workspace + const workspaceUri = await resolveRelativePathInDir(expandedPath, ide); + if (workspaceUri) { + // Relative paths resolved within workspace are always within workspace + return { + uri: workspaceUri, + displayPath: expandedPath, + isAbsolute: false, + isWithinWorkspace: true, + }; + } + + return null; +} + +/** + * Normalizes a path for display purposes. + * Contracts home directory to ~ on Unix-like systems. + */ +export function normalizeDisplayPath(fullPath: string): string { + const home = os.homedir(); + if (fullPath.startsWith(home)) { + return "~" + fullPath.slice(home.length); + } + return fullPath; +} diff --git a/extensions/cli/src/util/pathResolver.ts b/extensions/cli/src/util/pathResolver.ts new file mode 100644 index 00000000000..260123f5796 --- /dev/null +++ b/extensions/cli/src/util/pathResolver.ts @@ -0,0 +1,33 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +/** + * Resolves user-provided paths for CLI context. + * Handles absolute paths, tilde paths, and relative paths. + */ +export function resolveInputPath(inputPath: string): string | null { + // Trim whitespace + const trimmedPath = inputPath.trim(); + + // Expand tilde paths + let expandedPath = trimmedPath; + if (trimmedPath.startsWith("~/")) { + expandedPath = path.join(os.homedir(), trimmedPath.slice(2)); + } else if (trimmedPath === "~") { + expandedPath = os.homedir(); + } else if (trimmedPath.startsWith("./")) { + // Keep relative paths starting with ./ as is (relative to cwd)z + expandedPath = trimmedPath; + } + + // Resolve the path (handles both absolute and relative paths) + const resolvedPath = path.resolve(expandedPath); + + // Check if the path exists + if (fs.existsSync(resolvedPath)) { + return resolvedPath; + } + + return null; +} From bf9a19ada539d4a2fc2665c7a87969fe7ae8035f Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 16 Oct 2025 16:09:09 -0700 Subject: [PATCH 2/3] fix: pass preprocessed args to core --- core/core.ts | 18 +++--------------- gui/src/redux/thunks/evaluateToolPolicies.ts | 6 +++--- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/core/core.ts b/core/core.ts index ae4c352cacc..87512ccf50c 100644 --- a/core/core.ts +++ b/core/core.ts @@ -1102,26 +1102,14 @@ export class Core { return { policy: basePolicy }; } - // Preprocess args if preprocessor exists - let processedArgs = args; - if (tool.preprocessArgs) { - try { - processedArgs = await tool.preprocessArgs(args, { ide: this.ide }); - } catch (e) { - // If preprocessing fails, use original args - console.warn(`Failed to preprocess args for ${toolName}:`, e); - processedArgs = args; - } - } - // Extract display value for specific tools let displayValue: string | undefined; - if (toolName === "runTerminalCommand" && processedArgs.command) { - displayValue = processedArgs.command as string; + if (toolName === "runTerminalCommand" && args.command) { + displayValue = args.command as string; } if (tool.evaluateToolCallPolicy) { - const evaluatedPolicy = tool.evaluateToolCallPolicy(basePolicy, processedArgs); + const evaluatedPolicy = tool.evaluateToolCallPolicy(basePolicy, args); return { policy: evaluatedPolicy, displayValue }; } return { policy: basePolicy, displayValue }; diff --git a/gui/src/redux/thunks/evaluateToolPolicies.ts b/gui/src/redux/thunks/evaluateToolPolicies.ts index 37c009210a2..57085526163 100644 --- a/gui/src/redux/thunks/evaluateToolPolicies.ts +++ b/gui/src/redux/thunks/evaluateToolPolicies.ts @@ -38,14 +38,14 @@ async function evaluateToolPolicy( )?.defaultToolPolicy ?? DEFAULT_TOOL_SETTING; - // Use already parsed arguments - const parsedArgs = toolCallState.parsedArgs || {}; + // Use preprocessed arguments if available, otherwise fall back to parsed arguments + const args = toolCallState.processedArgs || toolCallState.parsedArgs || {}; const toolName = toolCallState.toolCall.function.name; const result = await ideMessenger.request("tools/evaluatePolicy", { toolName, basePolicy, - args: parsedArgs, + args, }); // Evaluate the policy dynamically From bec90ab8413b1d2d4880ac7456941713c43c143d Mon Sep 17 00:00:00 2001 From: Continue Agent Date: Fri, 17 Oct 2025 00:06:32 +0000 Subject: [PATCH 3/3] docs: add documentation for file access outside workspace - Add comprehensive guide at ide-extensions/agent/file-access-outside-workspace.mdx - Update how-it-works.mdx for Agent mode with file access info - Update how-it-works.mdx for Plan mode with file access info - Update how-to-customize.mdx with file access section - Add new doc page to docs.json navigation The new documentation covers: - How file access works with path resolution and workspace boundaries - Supported path formats (relative, absolute, tilde, file://, UNC) - Security and permission policies - Best practices and troubleshooting - Examples for various use cases Generated with [Continue](https://continue.dev) Co-Authored-By: Continue Co-authored-by: Username --- docs/docs.json | 3 +- .../agent/file-access-outside-workspace.mdx | 230 ++++++++++++++++++ docs/ide-extensions/agent/how-it-works.mdx | 10 +- .../ide-extensions/agent/how-to-customize.mdx | 4 + docs/ide-extensions/plan/how-it-works.mdx | 10 +- 5 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 docs/ide-extensions/agent/file-access-outside-workspace.mdx diff --git a/docs/docs.json b/docs/docs.json index 5f7f30addfc..da56289689f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -48,7 +48,8 @@ "ide-extensions/agent/plan-mode", "ide-extensions/agent/model-setup", "ide-extensions/agent/how-to-customize", - "ide-extensions/agent/context-selection" + "ide-extensions/agent/context-selection", + "ide-extensions/agent/file-access-outside-workspace" ] }, { diff --git a/docs/ide-extensions/agent/file-access-outside-workspace.mdx b/docs/ide-extensions/agent/file-access-outside-workspace.mdx new file mode 100644 index 00000000000..7cd49239efa --- /dev/null +++ b/docs/ide-extensions/agent/file-access-outside-workspace.mdx @@ -0,0 +1,230 @@ +--- +title: "File Access Outside Workspace" +description: "Learn how Continue's Agent and Plan modes handle file access outside the IDE workspace, including permission requirements and supported path formats." +--- + +## Overview + +Continue's Agent and Plan modes can read files and list directories both within and outside your IDE workspace. For security, accessing files outside the workspace requires explicit user permission. + +## How File Access Works + +When you use tools like `read_file`, `ls`, `view_subdirectory`, or `read_file_range`, Continue: + +1. **Resolves the path** - Normalizes the provided path (relative, absolute, tilde, or file:// URI) +2. **Checks workspace boundaries** - Determines if the path is within or outside your workspace +3. **Applies access policy** - Files within workspace are read automatically; files outside require permission + +## Supported Path Formats + +Continue supports multiple path formats for flexible file access: + +### Relative Paths + +Paths relative to your workspace root: + +``` +src/components/Button.tsx +../shared-utils/helpers.js +``` + +These are always resolved within the workspace and don't require permission. + +### Absolute Paths + +Full system paths: + +``` +/home/user/documents/notes.txt +C:\Users\username\Desktop\config.json +``` + +### Tilde Paths + +Home directory expansion: + +``` +~/Documents/project-notes.md +~/.ssh/config +``` + +The `~` expands to your home directory (e.g., `/home/user` or `C:\Users\username`). + +### File URIs + +File protocol URIs: + +``` +file:///home/user/documents/notes.txt +file:///C:/Users/username/Desktop/config.json +``` + +These are automatically decoded and resolved to system paths. + +### Windows Network Paths + +UNC paths for network shares: + +``` +\\server\share\folder\file.txt +``` + +## Workspace Boundary Detection + +Continue determines workspace boundaries by checking if a resolved path starts with any of your IDE's workspace directories. This applies to: + +- Single-folder workspaces +- Multi-root workspaces (e.g., VS Code multi-root) +- Monorepo setups with multiple workspace directories + +### Example Scenarios + +**Scenario 1: Reading within workspace** + +``` +Workspace: /home/user/myproject +Request: read_file("src/main.ts") +Result: ✅ Allowed automatically (within workspace) +``` + +**Scenario 2: Reading outside workspace** + +``` +Workspace: /home/user/myproject +Request: read_file("~/Documents/external-notes.md") +Result: ⚠️ Requires user permission (outside workspace) +``` + +**Scenario 3: Reading with absolute path** + +``` +Workspace: /home/user/myproject +Request: read_file("/etc/hosts") +Result: ⚠️ Requires user permission (outside workspace) +``` + +## Security and Permissions + +### Permission Policy + +- **Within workspace**: Files are accessible without permission (policy: `allowedWithoutPermission`) +- **Outside workspace**: Files require explicit user permission (policy: `allowedWithPermission`) +- **Disabled tools**: Respects tool policy settings if explicitly disabled + +### User Experience + +When Agent or Plan mode attempts to access a file outside the workspace: + +1. The tool call is displayed with the full path +2. A permission prompt appears asking for confirmation +3. You can approve or deny the access +4. Future accesses may still require permission depending on your settings + +### Best Practices + + + Be cautious when granting permission to access files outside your workspace. Verify the path and ensure you trust the operation being performed. + + +- **Review paths carefully** - Check that file paths in permission prompts are expected +- **Understand the context** - Know why the agent is requesting external file access +- **Limit scope** - Consider moving frequently accessed external files into your workspace +- **Use tool policies** - Configure tool policies in your settings to control access patterns + +## Available Tools with External Access + +The following tools support reading files outside the workspace: + +### Read-Only Tools (Plan & Agent Mode) + +- **read_file** - Read complete file contents +- **read_file_range** - Read specific line ranges from a file +- **ls** - List files and directories +- **view_subdirectory** - View directory structure + +### Path Resolution + +All these tools use the same path resolution logic: + +1. Parse the input path (relative, absolute, tilde, URI) +2. Resolve to an absolute system path +3. Check workspace boundaries +4. Apply appropriate permission policy + +## Examples + +### Reading Configuration Files + +``` +# Agent prompt +"Read my SSH config file at ~/.ssh/config and summarize the hosts" + +# Continue resolves ~/. ssh/config to /home/user/.ssh/config +# Prompts for permission since it's outside workspace +# Reads and provides summary +``` + +### Listing External Directories + +``` +# Agent prompt +"List all markdown files in ~/Documents/notes" + +# Continue resolves ~/Documents/notes +# Prompts for permission +# Lists .md files in that directory +``` + +### Comparing Files Across Locations + +``` +# Agent prompt +"Compare src/config.json with /etc/app/default-config.json" + +# src/config.json - within workspace, reads immediately +# /etc/app/default-config.json - outside workspace, prompts for permission +``` + +## Troubleshooting + +### Permission Denied Errors + +If you see permission denied errors: + +1. **Check file system permissions** - Ensure your user account can read the file +2. **Verify path resolution** - Confirm the path resolves correctly for your OS +3. **Review tool policies** - Check if the tool is disabled in settings + +### Path Not Found + +If paths aren't resolving: + +1. **Use absolute paths** - Try providing full system paths +2. **Check tilde expansion** - Verify `~` expands to the correct home directory +3. **Escape special characters** - Quote paths with spaces or special characters +4. **Windows paths** - Use forward slashes or properly escaped backslashes + +### Unexpected Permission Prompts + +If you're prompted for files you expect to be in the workspace: + +1. **Verify workspace configuration** - Check your IDE's workspace settings +2. **Check symbolic links** - Symlinks may resolve outside workspace boundaries +3. **Review path normalization** - Ensure paths are being resolved correctly + +## Related Documentation + + + + Learn about tool handshakes and built-in tools + + + Understand read-only tool restrictions in Plan mode + + + Configure MCP servers for additional tools + + + Configure tool policies and permissions + + diff --git a/docs/ide-extensions/agent/how-it-works.mdx b/docs/ide-extensions/agent/how-it-works.mdx index ccb1a65b67b..dc9b9f9ed32 100644 --- a/docs/ide-extensions/agent/how-it-works.mdx +++ b/docs/ide-extensions/agent/how-it-works.mdx @@ -29,18 +29,22 @@ Continue includes several built-in tools which provide the model access to IDE f In Plan mode, only these read-only tools are available: -- **Read file** (`read_file`) +- **Read file** (`read_file`) - Supports paths outside workspace with permission - **Read currently open file** (`read_currently_open_file`) -- **List directory** (`ls`) +- **List directory** (`ls`) - Supports paths outside workspace with permission - **Glob search** (`glob_search`) - **Grep search** (`grep_search`) - **Fetch URL content** (`fetch_url_content`) - **Search web** (`search_web`) - **View diff** (`view_diff`) - **View repo map** (`view_repo_map`) -- **View subdirectory** (`view_subdirectory`) +- **View subdirectory** (`view_subdirectory`) - Supports paths outside workspace with permission - **Codebase tool** (`codebase_tool`) + + Tools like `read_file`, `ls`, and `view_subdirectory` can access files outside your workspace with explicit permission. Learn more about [File Access Outside Workspace](/ide-extensions/agent/file-access-outside-workspace). + + ### What Tools Are Available in Agent Mode (All Tools) In Agent mode, all tools are available including the read-only tools above plus: diff --git a/docs/ide-extensions/agent/how-to-customize.mdx b/docs/ide-extensions/agent/how-to-customize.mdx index c2294b647f8..61bce87ca34 100644 --- a/docs/ide-extensions/agent/how-to-customize.mdx +++ b/docs/ide-extensions/agent/how-to-customize.mdx @@ -46,3 +46,7 @@ To manage tool policies: 3. You can also toggle groups of tools on/off Tool policies are stored locally per user. + +## File Access Outside Workspace + +Agent mode can read files and list directories outside your workspace with explicit permission. Learn more about [File Access Outside Workspace](/ide-extensions/agent/file-access-outside-workspace), including supported path formats (relative, absolute, tilde, file:// URIs) and security considerations. diff --git a/docs/ide-extensions/plan/how-it-works.mdx b/docs/ide-extensions/plan/how-it-works.mdx index 8555e68ffb2..425a9a4b8fe 100644 --- a/docs/ide-extensions/plan/how-it-works.mdx +++ b/docs/ide-extensions/plan/how-it-works.mdx @@ -29,18 +29,22 @@ When you select Plan mode: Plan mode includes these read-only built-in tools: -- **Read file** (`read_file`): Read the contents of any file in the project +- **Read file** (`read_file`): Read the contents of any file in the project (supports paths outside workspace with permission) - **Read currently open file** (`read_currently_open_file`): Read the contents of the currently open file -- **List directory** (`ls`): List files and directories +- **List directory** (`ls`): List files and directories (supports paths outside workspace with permission) - **Glob search** (`glob_search`): Search for files matching a pattern - **Grep search** (`grep_search`): Search file contents using regex patterns - **Fetch URL content** (`fetch_url_content`): Retrieve content from web URLs - **Search web** (`search_web`): Perform web searches for additional context - **View diff** (`view_diff`): View the current git diff - **View repo map** (`view_repo_map`): Get an overview of the repository structure -- **View subdirectory** (`view_subdirectory`): Get a detailed view of a specific directory +- **View subdirectory** (`view_subdirectory`): Get a detailed view of a specific directory (supports paths outside workspace with permission) - **Codebase tool** (`codebase_tool`): Advanced codebase analysis capabilities + + Tools like `read_file`, `ls`, and `view_subdirectory` can access files outside your workspace with explicit permission. Learn more about [File Access Outside Workspace](/ide-extensions/agent/file-access-outside-workspace). + + ### MCP tools support In addition to built-in read-only tools, Plan mode also supports all MCP (Model Context Protocol) tools. This allows integration with external services that provide additional context or analysis capabilities without modifying your local environment.