diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts index 23f62a3a87..7aa20fa0b4 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts @@ -1,8 +1,13 @@ // FileSearch tool based on ListDirectory implementation -import { CommandValidation, InvokeOutput, requiresPathAcceptance, validatePath } from './toolShared' +import { + CommandValidation, + InvokeOutput, + requiresPathAcceptance, + resolveCanonicalPath, + validatePath, +} from './toolShared' import { workspaceUtils } from '@aws/lsp-core' import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { sanitize } from '@aws/lsp-core/out/util/path' import { DEFAULT_EXCLUDE_DIRS, DEFAULT_EXCLUDE_FILES } from '../../chat/constants' import { CancellationToken } from '@aws/language-server-runtimes/protocol' const Fuse = require('fuse.js') @@ -56,7 +61,7 @@ export class FileSearch { } public async invoke(params: FileSearchParams, token?: CancellationToken): Promise { - const path = sanitize(params.path) + const path = await resolveCanonicalPath(params.path) try { // Get all files and directories const listing = await workspaceUtils.readDirectoryRecursively( diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsRead.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsRead.ts index e1c46b93da..093375e24e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsRead.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsRead.ts @@ -1,5 +1,10 @@ -import { sanitize } from '@aws/lsp-core/out/util/path' -import { CommandValidation, InvokeOutput, requiresPathAcceptance, validatePath } from './toolShared' +import { + CommandValidation, + InvokeOutput, + requiresPathAcceptance, + resolveCanonicalPath, + validatePath, +} from './toolShared' import { Features } from '@aws/language-server-runtimes/server-interface/server' import { FSREAD_MAX_PER_FILE, FSREAD_MAX_TOTAL } from '../constants/constants' @@ -57,7 +62,7 @@ export class FsRead { public async invoke(params: FsReadParams): Promise { const fileResult: FileReadResult[] = [] for (const path of params.paths) { - const sanitizedPath = sanitize(path) + const sanitizedPath = await resolveCanonicalPath(path) const content = await this.readFile(sanitizedPath) this.logging.info(`Read file: ${sanitizedPath}, size: ${content.length}`) fileResult.push({ path, content, truncated: false }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsReplace.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsReplace.ts index e0a0edbc85..0c471ec481 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsReplace.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsReplace.ts @@ -1,7 +1,12 @@ -import { CommandValidation, ExplanatoryParams, InvokeOutput, requiresPathAcceptance } from './toolShared' +import { + CommandValidation, + ExplanatoryParams, + InvokeOutput, + requiresPathAcceptance, + resolveCanonicalPath, +} from './toolShared' import { EmptyPathError, EmptyDiffsError, FileNotExistsError, TextNotFoundError, MultipleMatchesError } from '../errors' import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { sanitize } from '@aws/lsp-core/out/util/path' import * as os from 'os' interface BaseParams extends ExplanatoryParams { @@ -37,7 +42,7 @@ export class FsReplace { if (!params.diffs || params.diffs.length === 0) { throw new EmptyDiffsError() } - const sanitizedPath = sanitize(params.path) + const sanitizedPath = await resolveCanonicalPath(params.path) const fileExists = await this.workspace.fs.exists(sanitizedPath) if (!fileExists) { throw new FileNotExistsError() @@ -45,7 +50,7 @@ export class FsReplace { } public async invoke(params: FsReplaceParams): Promise { - const sanitizedPath = sanitize(params.path) + const sanitizedPath = await resolveCanonicalPath(params.path) await this.handleReplace(params, sanitizedPath) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts index a60d3699b3..832d8e36dc 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts @@ -1,7 +1,12 @@ -import { CommandValidation, ExplanatoryParams, InvokeOutput, requiresPathAcceptance } from './toolShared' +import { + CommandValidation, + ExplanatoryParams, + InvokeOutput, + requiresPathAcceptance, + resolveCanonicalPath, +} from './toolShared' import { EmptyPathError, MissingContentError, FileExistsWithSameContentError, EmptyAppendContentError } from '../errors' import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { sanitize } from '@aws/lsp-core/out/util/path' import { LocalProjectContextController } from '../../../shared/localProjectContextController' import { URI } from 'vscode-uri' @@ -41,7 +46,7 @@ export class FsWrite { if (!params.path) { throw new EmptyPathError() } - const sanitizedPath = sanitize(params.path) + const sanitizedPath = await resolveCanonicalPath(params.path) switch (params.command) { case 'create': { if (params.fileText === undefined) { @@ -65,7 +70,7 @@ export class FsWrite { } public async invoke(params: FsWriteParams): Promise { - const sanitizedPath = sanitize(params.path) + const sanitizedPath = await resolveCanonicalPath(params.path) let content = '' switch (params.command) { case 'create': diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/listDirectory.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/listDirectory.ts index 94cd3fdc12..62e8ca8289 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/listDirectory.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/listDirectory.ts @@ -1,8 +1,13 @@ // Port from VSC: https://github.com/aws/aws-toolkit-vscode/blob/0eea1d8ca6e25243609a07dc2a2c31886b224baa/packages/core/src/codewhispererChat/tools/listDirectory.ts#L19 -import { CommandValidation, InvokeOutput, requiresPathAcceptance, validatePath } from './toolShared' +import { + CommandValidation, + InvokeOutput, + requiresPathAcceptance, + resolveCanonicalPath, + validatePath, +} from './toolShared' import { CancellationError, workspaceUtils } from '@aws/lsp-core' import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { sanitize } from '@aws/lsp-core/out/util/path' import { DEFAULT_EXCLUDE_DIRS, DEFAULT_EXCLUDE_FILES } from '../../chat/constants' import { CancellationToken } from '@aws/language-server-runtimes/protocol' @@ -59,7 +64,7 @@ export class ListDirectory { } public async invoke(params: ListDirectoryParams, token?: CancellationToken): Promise { - const path = sanitize(params.path) + const path = await resolveCanonicalPath(params.path) try { const result = await workspaceUtils.readDirectoryWithTreeOutput( { workspace: this.workspace, logging: this.logging }, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts index 0776a90819..2eb2a29df1 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts @@ -1,6 +1,7 @@ import { Features } from '@aws/language-server-runtimes/server-interface/server' import { workspaceUtils } from '@aws/lsp-core' import { getWorkspaceFolderPaths } from '@aws/lsp-core/out/util/workspaceUtils' +import { sanitize } from '@aws/lsp-core/out/util/path' import * as fs from 'fs' import * as path from 'path' import { CommandCategory } from './executeBash' @@ -62,6 +63,17 @@ export async function resolveSymlinkAwarePath(inputPath: string): Promise { + return resolveSymlinkAwarePath(sanitize(inputPath)) +} + /** * Canonicalize workspace folder paths through the filesystem so boundary * comparisons stay accurate even when a workspace lives under a symlinked @@ -202,15 +214,12 @@ export async function requiresPathAcceptance( approvedPaths?: Map> ): Promise { try { - // Canonicalize the path in a symlink-aware way before the - // workspace-boundary check. This resolves symlinks at every segment, - // including a symlink at the leaf whose target does not exist yet - // (a "dangling" symlink). A string-only resolve, or an fs.realpath - // that silently falls back to the literal link name when the target - // is missing, would treat such a link as in-workspace based on its - // name alone even though a write or read through it would land - // outside the workspace. - const canonicalPath = await resolveSymlinkAwarePath(inputPath) + // Canonicalize in a symlink-aware way before the workspace-boundary + // check: a link whose name sits inside the workspace can point outside + // it, including when the target does not exist yet (a dangling link). + // The I/O tools resolve via the same helper and operate on that value, + // so the boundary decision and the operation share one resolved path. + const canonicalPath = await resolveCanonicalPath(inputPath) // Then check if the path is already approved for this specific tool if (isPathApproved(canonicalPath, toolName, approvedPaths)) {