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 67e0bb1718d..c8c75306092 100644 --- a/core/tools/implementations/lsTool.ts +++ b/core/tools/implementations/lsTool.ts @@ -2,14 +2,15 @@ import ignore from "ignore"; import { ToolImpl } from "."; import { walkDir } from "../../indexing/walkDir"; -import { resolveRelativePathInDir } from "../../util/ideUtils"; +import { resolveInputPath } from "../../util/pathResolver"; import { ContinueError, ContinueErrorReason } from "../../util/errors"; 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, "/"); @@ -19,15 +20,15 @@ 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 ContinueError( ContinueErrorReason.DirectoryNotFound, - `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, @@ -39,12 +40,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 8daee746212..6c55912eef3 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 "."; @@ -9,31 +9,35 @@ import { ContinueError, ContinueErrorReason } from "../../util/errors"; 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 ContinueError( ContinueErrorReason.FileNotFound, - `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 0ff97d18991..7b5f47338e0 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"; import { ContinueError, ContinueErrorReason } from "../../util/errors"; @@ -31,16 +32,20 @@ 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 ContinueError( ContinueErrorReason.FileNotFound, - `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, @@ -52,21 +57,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 de546833852..8a709a8a012 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 { ContinueError, ContinueErrorReason } from "../../util/errors"; @@ -8,17 +8,17 @@ 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) { + if (!resolvedPath) { throw new ContinueError( ContinueErrorReason.DirectoryNotFound, - `Directory path "${directory_path}" does not exist.`, + `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, }); @@ -26,7 +26,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/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. 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; +} 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