diff --git a/chat-client/package.json b/chat-client/package.json index df0378f3e4..88c36b9cc9 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -26,7 +26,7 @@ "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", "@aws/language-server-runtimes": "^0.2.123", - "@aws/language-server-runtimes-types": "^0.1.50", + "@aws/language-server-runtimes-types": "^0.1.52", "@aws/mynah-ui": "^4.36.4" }, "devDependencies": { diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 3b42249331..99b1a03fd7 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -52,6 +52,7 @@ import { MynahIcons, CustomQuickActionCommand, ConfigTexts, + DropdownListOption, } from '@aws/mynah-ui' import { VoteParams } from '../contracts/telemetry' import { Messager } from './messager' @@ -675,6 +676,30 @@ export const createMynahUi = ( throw new Error(`Unhandled tab bar button id: ${buttonId}`) }, + onDropDownOptionChange: (tabId: string, messageId: string, value: DropdownListOption[]) => { + // process data before sending + // map data to Record + // value: `${serverName}@${toolName}` + const metadata: Record = {} + const option = value[0] + const [serverName, toolName] = option.value.split('@') + const new_permission = option.id + + metadata['toolName'] = toolName + metadata['serverName'] = serverName + metadata['permission'] = new_permission + + const payload: ButtonClickParams = { + tabId, + messageId, + buttonId: 'trust-command', + metadata, + } + messager.onButtonClick(payload) + }, + onDropDownLinkClick: (tabId: string, actionId: string, destination: string) => { + messager.onMcpServerClick(actionId, destination) + }, onPromptInputOptionChange: (tabId, optionsValues) => { if (agenticMode) { handlePromptInputChange(mynahUi, tabId, optionsValues) @@ -1305,6 +1330,9 @@ export const createMynahUi = ( fileList: undefined, } : undefined, + quickSettings: message.summary.content.quickSettings + ? message.summary.content.quickSettings + : undefined, } : undefined, collapsedContent: @@ -1400,6 +1428,7 @@ export const createMynahUi = ( ? { 'insert-to-cursor': null } : undefined, ...(shouldMute ? { muted: true } : {}), + quickSettings: message.quickSettings ? message.quickSettings : undefined, } } diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index 3a6012471a..6340add2d8 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -202,7 +202,7 @@ Select code & ask me to explain, debug or optimize it, or type \`/\` for quick a tabBarButtons.push({ id: McpServerTabButtonId, icon: MynahIcons.TOOLS, - description: 'Configure MCP servers', + description: 'Configure MCP servers and Built-in tools', }) } diff --git a/chat-client/src/client/withAdapter.ts b/chat-client/src/client/withAdapter.ts index 3f10e11752..0db386456d 100644 --- a/chat-client/src/client/withAdapter.ts +++ b/chat-client/src/client/withAdapter.ts @@ -64,6 +64,8 @@ export const withAdapter = ( onPromptTopBarItemAdded: addDefaultRouting('onPromptTopBarItemAdded'), onPromptTopBarItemRemoved: addDefaultRouting('onPromptTopBarItemRemoved'), onPromptTopBarButtonClick: addDefaultRouting('onPromptTopBarButtonClick'), + onDropDownOptionChange: addDefaultRouting('onDropDownOptionChange'), + onDropDownLinkClick: addDefaultRouting('onDropDownLinkClick'), /** * Handler with special routing logic diff --git a/chat-client/src/contracts/chatClientAdapter.ts b/chat-client/src/contracts/chatClientAdapter.ts index 01d9d19c22..24fa3ce111 100644 --- a/chat-client/src/contracts/chatClientAdapter.ts +++ b/chat-client/src/contracts/chatClientAdapter.ts @@ -43,6 +43,8 @@ export interface ChatEventHandler | 'onPromptTopBarItemAdded' | 'onPromptTopBarItemRemoved' | 'onPromptTopBarButtonClick' + | 'onDropDownOptionChange' + | 'onDropDownLinkClick' > {} /** diff --git a/package-lock.json b/package-lock.json index b42f206c44..bbfe4024d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -256,7 +256,7 @@ "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", "@aws/language-server-runtimes": "^0.2.123", - "@aws/language-server-runtimes-types": "^0.1.50", + "@aws/language-server-runtimes-types": "^0.1.52", "@aws/mynah-ui": "^4.36.4" }, "devDependencies": { @@ -14150,6 +14150,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -14505,6 +14506,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "engines": { "node": ">=0.12" }, @@ -14743,6 +14745,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "engines": { "node": ">=10" }, @@ -16959,14 +16962,6 @@ "he": "bin/he" } }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -19689,11 +19684,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/just-clone": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.2.0.tgz", - "integrity": "sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==" - }, "node_modules/just-extend": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", @@ -20351,17 +20341,6 @@ "tmpl": "1.0.5" } }, - "node_modules/marked": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", - "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -20752,23 +20731,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -22087,11 +22049,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-srcset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", - "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -22627,33 +22584,6 @@ "node": ">= 0.4" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/prebuild-install": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.6.tgz", @@ -24058,85 +23988,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/sanitize-html": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", - "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", - "dependencies": { - "deepmerge": "^4.2.2", - "escape-string-regexp": "^4.0.0", - "htmlparser2": "^8.0.0", - "is-plain-object": "^5.0.0", - "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" - } - }, - "node_modules/sanitize-html/node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, - "node_modules/sanitize-html/node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", @@ -24891,14 +24742,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -26587,11 +26430,6 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, - "node_modules/unescape-html": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unescape-html/-/unescape-html-1.1.0.tgz", - "integrity": "sha512-O9/yBNqIkArjS597iHez5hAaAdn7b8/230SX8IncgXAX5tWI9XlEQYaz6Qbou0Sloa9n6lx9G5s6hg5qhJyzGg==" - }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts index 72217fae07..c7b7cde3ba 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -37,6 +37,7 @@ import { SUFFIX_PERMISSION, SUFFIX_UNDOALL, SUFFIX_EXPLANATION, + BUTTON_TRUST_COMMAND, } from './constants/toolConstants' import { SendMessageCommandInput, @@ -165,7 +166,7 @@ import { CancellationError, workspaceUtils } from '@aws/lsp-core' import { FsRead, FsReadParams } from './tools/fsRead' import { ListDirectory, ListDirectoryParams } from './tools/listDirectory' import { FsWrite, FsWriteParams } from './tools/fsWrite' -import { ExecuteBash, ExecuteBashParams } from './tools/executeBash' +import { commandCategories, ExecuteBash, ExecuteBashParams } from './tools/executeBash' import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared' import { validatePathBasic, validatePathExists, validatePaths as validatePathsSync } from './utils/pathValidation' import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch' @@ -189,6 +190,9 @@ import { DEFAULT_WINDOW_REJECT_SHORTCUT, DEFAULT_MACOS_STOP_SHORTCUT, DEFAULT_WINDOW_STOP_SHORTCUT, + OUT_OF_WORKSPACE_WARNING_MSG, + CREDENTIAL_FILE_WARNING_MSG, + BINARY_FILE_WARNING_MSG, } from './constants/constants' import { AgenticChatError, @@ -229,6 +233,7 @@ import { getLatestAvailableModel } from './utils/agenticChatControllerHelper' import { ActiveUserTracker } from '../../shared/activeUserTracker' import { UserContext } from '../../client/token/codewhispererbearertokenclient' import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' +import { McpPermissionType, MCPServerPermission } from './tools/mcp/mcpTypes' import { DisplayFindings } from './tools/qCodeAnalysis/displayFindings' import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager' @@ -392,24 +397,78 @@ export class AgenticChatController implements ChatHandlers { params.buttonId === BUTTON_RUN_SHELL_COMMAND || params.buttonId === BUTTON_REJECT_SHELL_COMMAND || params.buttonId === BUTTON_REJECT_MCP_TOOL || - params.buttonId === BUTTON_ALLOW_TOOLS + params.buttonId === BUTTON_ALLOW_TOOLS || + params.buttonId === BUTTON_TRUST_COMMAND ) { if (!session.data) { return { success: false, failureReason: `could not find chat session for tab: ${params.tabId} ` } } + // update permission if it's auto-run + if (params.buttonId === BUTTON_TRUST_COMMAND) { + // get result from metadata + const toolName = params.metadata!['toolName'] + const new_permission = params.metadata!['permission'] + const serverName = params.metadata!['serverName'] + + const current_permission = McpManager.instance.getToolPerm(serverName, toolName) + // only trigger update if curren != previous + if (current_permission !== new_permission) { + // generate perm object + const perm = await this.#mcpEventHandler.generateEmptyBuiltInToolPermission() + + // load updated permission + perm.toolPerms[toolName] = new_permission as McpPermissionType + + // update permission + try { + await McpManager.instance.updateServerPermission(serverName, perm) + // if the new permission is asks --> only update permission, dont continue + if (new_permission === 'ask') { + return { + success: true, + } + } + } catch (error) { + this.#features.logging.error(`Failed to save MCP permissions: ${error}`) + return { + success: false, + failureReason: `Failed to update permission for ${toolName}`, + } + } + } else { + // break, because nothing happen + return { + success: true, + } + } + } // For 'allow-tools', remove suffix as permission card needs to be seperate from file list card const messageId = - params.buttonId === BUTTON_ALLOW_TOOLS && params.messageId.endsWith(SUFFIX_PERMISSION) + (params.buttonId === BUTTON_ALLOW_TOOLS || params.buttonId === BUTTON_TRUST_COMMAND) && + params.messageId.endsWith(SUFFIX_PERMISSION) ? params.messageId.replace(SUFFIX_PERMISSION, '') : params.messageId - const handler = session.data.getDeferredToolExecution(messageId) if (!handler?.reject || !handler.resolve) { + if (params.buttonId === BUTTON_TRUST_COMMAND) { + // change permission of a completed task --> no handler + // should not return an error because it's a expected behavior + return { + success: true, + } + } return { success: false, failureReason: `could not find deferred tool execution for message: ${messageId} `, } } + if (params.buttonId === BUTTON_TRUST_COMMAND && params.metadata!['permission'] === 'deny') { + handler.reject(new ToolApprovalException('Command was denied.', true)) + this.#stoppedToolUses.add(messageId) + return { + success: true, + } + } params.buttonId === BUTTON_REJECT_SHELL_COMMAND || params.buttonId === BUTTON_REJECT_MCP_TOOL ? (() => { handler.reject(new ToolApprovalException('Command was rejected.', true)) @@ -1399,6 +1458,18 @@ export class AgenticChatController implements ChatHandlers { metric.setDimension('requestIds', metric.metric.requestIds) const toolNames = this.#toolUseLatencies.map(item => item.toolName) const toolUseIds = this.#toolUseLatencies.map(item => item.toolUseId) + + const builtInToolNames = new Set(this.#features.agent.getBuiltInToolNames()) + const permission: string[] = [] + + for (const toolName of toolNames) { + if (builtInToolNames.has(toolName)) { + permission.push(McpManager.instance.getToolPerm('Built-in', toolName)) + } else { + // TODO: determine mcp-server of the current tool to get permission + } + } + this.#telemetryController.emitAgencticLoop_InvokeLLM( response.$metadata.requestId!, conversationId, @@ -1414,7 +1485,8 @@ export class AgenticChatController implements ChatHandlers { this.#timeBetweenChunks, session.pairProgrammingMode, this.#abTestingAllocation?.experimentName, - this.#abTestingAllocation?.userVariation + this.#abTestingAllocation?.userVariation, + permission ) } else { // Send an error card to UI? @@ -1646,7 +1718,9 @@ export class AgenticChatController implements ChatHandlers { resultStream: AgenticChatResultStream, promptBlockId: number, session: ChatSessionService, - toolName: string + toolName: string, + commandCategory?: CommandCategory, + tabId?: string ) { const deferred = this.#createDeferred() session.setDeferredToolExecution(toolUse.toolUseId!, deferred.resolve, deferred.reject) @@ -1654,7 +1728,7 @@ export class AgenticChatController implements ChatHandlers { await deferred.promise // Note: we want to overwrite the button block because it already exists in the stream. await resultStream.overwriteResultBlock( - this.#getUpdateToolConfirmResult(toolUse, true, toolName), + this.#getUpdateToolConfirmResult(toolUse, true, toolName, undefined, commandCategory, tabId), promptBlockId ) } @@ -1710,6 +1784,10 @@ export class AgenticChatController implements ChatHandlers { }) } } + + // for later use + let finalCommandCategory: CommandCategory | undefined + switch (toolUse.name) { case FS_READ: case LIST_DIRECTORY: @@ -1732,9 +1810,9 @@ export class AgenticChatController implements ChatHandlers { const tool = new Tool(this.#features) // For MCP tools, get the permission from McpManager - // const permission = McpManager.instance.getToolPerm('Built-in', toolUse.name) + const permission = McpManager.instance.getToolPerm('Built-in', toolUse.name) // If permission is 'alwaysAllow', we don't need to ask for acceptance - // const builtInPermission = permission !== 'alwaysAllow' + const builtInPermission = permission !== 'alwaysAllow' // Get the approved paths from the session const approvedPaths = session.approvedPaths @@ -1745,19 +1823,43 @@ export class AgenticChatController implements ChatHandlers { approvedPaths ) + finalCommandCategory = commandCategory + + const isExecuteBash = toolUse.name === EXECUTE_BASH + + // check if tool execution's path is out of workspace + const isOutOfWorkSpace = warning === OUT_OF_WORKSPACE_WARNING_MSG + // check if tool involved secured files + const isSecuredFilesInvoled = + warning === BINARY_FILE_WARNING_MSG || warning === CREDENTIAL_FILE_WARNING_MSG + // Honor built-in permission if available, otherwise use tool's requiresAcceptance - // const requiresAcceptance = builtInPermission || toolRequiresAcceptance + let toolRequiresAcceptance = + (builtInPermission || isOutOfWorkSpace || isSecuredFilesInvoled) ?? requiresAcceptance - if (requiresAcceptance || toolUse.name === EXECUTE_BASH) { + // if the command is read-only and in-workspace --> flip back to no approval needed + if ( + isExecuteBash && + commandCategory === CommandCategory.ReadOnly && + !isOutOfWorkSpace && + !requiresAcceptance + ) { + toolRequiresAcceptance = false + } + + if (toolRequiresAcceptance || isExecuteBash) { // for executeBash, we till send the confirmation message without action buttons const confirmationResult = this.#processToolConfirmation( toolUse, - requiresAcceptance, + toolRequiresAcceptance, warning, - commandCategory + commandCategory, + toolUse.name, + builtInPermission, + tabId ) cachedButtonBlockId = await chatResultStream.writeResultBlock(confirmationResult) - const isExecuteBash = toolUse.name === EXECUTE_BASH + if (isExecuteBash) { this.#telemetryController.emitInteractWithAgenticChat( 'GeneratedCommand', @@ -1768,13 +1870,15 @@ export class AgenticChatController implements ChatHandlers { this.#abTestingAllocation?.userVariation ) } - if (requiresAcceptance) { + if (toolRequiresAcceptance) { await this.waitForToolApproval( toolUse, chatResultStream, cachedButtonBlockId, session, - toolUse.name + toolUse.name, + commandCategory, + tabId ) } if (isExecuteBash) { @@ -1828,7 +1932,9 @@ export class AgenticChatController implements ChatHandlers { requiresAcceptance, warning, undefined, - toolName // Pass the original tool name here + toolName, // Pass the original tool name here, + undefined, + tabId ) cachedButtonBlockId = await chatResultStream.writeResultBlock(confirmation) await this.waitForToolApproval( @@ -1836,7 +1942,9 @@ export class AgenticChatController implements ChatHandlers { chatResultStream, cachedButtonBlockId, session, - toolName + toolName, + undefined, + tabId ) } @@ -1888,7 +1996,7 @@ export class AgenticChatController implements ChatHandlers { session.addApprovedPath(inputPath) } - const ws = this.#getWritableStream(chatResultStream, toolUse) + const ws = this.#getWritableStream(chatResultStream, toolUse, finalCommandCategory) const result = await this.#features.agent.runTool(toolUse.name, toolUse.input, token, ws) let toolResultContent: ToolResultContentBlock @@ -1991,7 +2099,7 @@ export class AgenticChatController implements ChatHandlers { break // — DEFAULT ⇒ MCP tools default: - await this.#handleMcpToolResult(toolUse, result, session, chatResultStream) + await this.#handleMcpToolResult(toolUse, result, session, chatResultStream, tabId) break } this.#updateUndoAllState(toolUse, session) @@ -2306,7 +2414,11 @@ export class AgenticChatController implements ChatHandlers { }) } - #getWritableStream(chatResultStream: AgenticChatResultStream, toolUse: ToolUse): WritableStream | undefined { + #getWritableStream( + chatResultStream: AgenticChatResultStream, + toolUse: ToolUse, + commandCategory?: CommandCategory + ): WritableStream | undefined { if (toolUse.name === CodeReview.toolName) { return this.#getToolOverWritableStream(chatResultStream, toolUse) } @@ -2325,7 +2437,14 @@ export class AgenticChatController implements ChatHandlers { const completedHeader: ChatMessage['header'] = { body: 'shell', - status: { status: 'success', icon: 'ok', text: 'Completed' }, + status: { + status: 'success', + icon: 'ok', + text: 'Completed', + ...(toolUse.name === EXECUTE_BASH + ? { description: this.#getCommandCategoryDescription(commandCategory ?? CommandCategory.ReadOnly) } + : {}), + }, buttons: [], } @@ -2376,10 +2495,12 @@ export class AgenticChatController implements ChatHandlers { toolUse: ToolUse, isAccept: boolean, originalToolName: string, - toolType?: string + toolType?: string, + commandCategory?: CommandCategory, + tabId?: string ): ChatResult { const toolName = originalToolName ?? (toolType || toolUse.name) - + const quickSettings = this.#buildQuickSettings(toolUse, toolName!, toolType, tabId) // Handle bash commands with special formatting if (toolName === EXECUTE_BASH) { return { @@ -2399,6 +2520,7 @@ export class AgenticChatController implements ChatHandlers { }), buttons: isAccept ? [this.#renderStopShellCommandButton()] : [], }, + quickSettings, } } @@ -2408,7 +2530,6 @@ export class AgenticChatController implements ChatHandlers { status: { status: 'info' | 'success' | 'warning' | 'error'; icon: string; text: string } } let body: string | undefined - switch (toolName) { case FS_REPLACE: case FS_WRITE: @@ -2439,6 +2560,7 @@ export class AgenticChatController implements ChatHandlers { default: // Default tool (not only MCP) + const quickSettings = this.#buildQuickSettings(toolUse, toolName, toolType, tabId) return { type: 'tool', messageId: toolUse.toolUseId!, @@ -2454,6 +2576,7 @@ export class AgenticChatController implements ChatHandlers { }, fileList: undefined, }, + quickSettings, }, collapsedContent: [ { @@ -2615,13 +2738,56 @@ export class AgenticChatController implements ChatHandlers { return defaultKey } + #buildQuickSettings(toolUse: ToolUse, toolName: string, toolType?: string, tabId?: string) { + const originalNames = McpManager.instance.getOriginalToolNames(toolUse.name!) + let serverName = 'Built-in' + let descriptionLinkText = 'More control, modify the commands' + if (originalNames) { + serverName = originalNames.serverName + toolName = originalNames.toolName + descriptionLinkText = 'Advanced' + } + const permission = McpManager.instance.getToolPerm(serverName, toolName) + return { + type: 'select' as 'select' | 'checkbox' | 'radio', // will update this later + messageId: this.#getMessageIdForToolUse(toolType, toolUse), + tabId: tabId!, + description: '', + descriptionLink: { + id: 'open-mcp-server', + text: descriptionLinkText, + destination: serverName, + }, + options: [ + { id: 'ask', label: 'Ask to run', value: `${serverName}@${toolName}`, selected: permission === 'ask' }, + { + id: 'alwaysAllow', + label: 'Always allow', + value: `${serverName}@${toolName}`, + selected: permission === 'alwaysAllow', + }, + ...(serverName !== 'Built-in' + ? [ + { + id: 'deny', + label: 'Deny', + value: `${serverName}@${toolName}`, + selected: permission === 'deny', + }, + ] + : []), + ], + } + } + #processToolConfirmation( toolUse: ToolUse, requiresAcceptance: Boolean, warning?: string, commandCategory?: CommandCategory, toolType?: string, - builtInPermission?: boolean + builtInPermission?: boolean, + tabId?: string ): ChatResult { const toolName = toolType || toolUse.name let buttons: Button[] = [] @@ -2639,16 +2805,11 @@ export class AgenticChatController implements ChatHandlers { } } let body: string | undefined - + const quickSettings = this.#buildQuickSettings(toolUse, toolName!, toolType, tabId) // Configure tool-specific UI elements switch (toolName) { - case EXECUTE_BASH: { + case 'executeBash': { const commandString = (toolUse.input as unknown as ExecuteBashParams).command - // get feature flag - const shortcut = - this.#features.lsp.getClientInitializeParams()?.initializationOptions?.aws?.awsClientCapabilities?.q - ?.shortcut - const runKey = this.#getKeyBinding('aws.amazonq.runCmdExecution') const rejectKey = this.#getKeyBinding('aws.amazonq.rejectCmdExecution') @@ -2684,16 +2845,12 @@ export class AgenticChatController implements ChatHandlers { : undefined header = { - status: requiresAcceptance - ? { - icon: statusIcon, - status: statusType, - position: 'left', - description: this.#getCommandCategoryDescription( - commandCategory ?? CommandCategory.ReadOnly - ), - } - : {}, + status: { + icon: statusIcon, + status: statusType, + position: 'left', + description: this.#getCommandCategoryDescription(commandCategory ?? CommandCategory.ReadOnly), + }, body: 'shell', buttons, } @@ -2769,7 +2926,7 @@ export class AgenticChatController implements ChatHandlers { body = builtInPermission ? `I need permission to read files.\n${formattedPaths.join('\n')}` : `I need permission to read files outside the workspace.\n${formattedPaths.join('\n')}` - } else { + } else if (toolName === 'listDirectory') { const readFilePath = (toolUse.input as unknown as ListDirectoryParams).path // Validate the path using our synchronous utility @@ -2779,6 +2936,11 @@ export class AgenticChatController implements ChatHandlers { body = builtInPermission ? `I need permission to list directories.\n\`${readFilePath}\`` : `I need permission to list directories outside the workspace.\n\`${readFilePath}\`` + } else { + const readFilePath = (toolUse.input as unknown as ListDirectoryParams).path + body = builtInPermission + ? `I need permission to search files.\n\`${readFilePath}\`` + : `I need permission to search files outside the workspace.\n\`${readFilePath}\`` } break } @@ -2806,6 +2968,7 @@ export class AgenticChatController implements ChatHandlers { messageId: this.#getMessageIdForToolUse(toolType, toolUse), header, body: warning ? (toolName === EXECUTE_BASH ? '' : '\n\n') + body : body, + quickSettings, } } else { return { @@ -2826,6 +2989,7 @@ export class AgenticChatController implements ChatHandlers { }, ], }, + quickSettings, }, collapsedContent: [ { @@ -4361,7 +4525,8 @@ export class AgenticChatController implements ChatHandlers { toolUse: ToolUse, result: any, session: ChatSessionService, - chatResultStream: AgenticChatResultStream + chatResultStream: AgenticChatResultStream, + tabId?: string ): Promise { // Early return if name or toolUseId is undefined if (!toolUse.name || !toolUse.toolUseId) { @@ -4371,6 +4536,7 @@ export class AgenticChatController implements ChatHandlers { // Get original server and tool names from the mapping const originalNames = McpManager.instance.getOriginalToolNames(toolUse.name) + const quickSettings = this.#buildQuickSettings(toolUse, toolUse.name, toolUse.name, tabId) if (originalNames) { const { serverName, toolName } = originalNames const def = McpManager.instance @@ -4391,6 +4557,7 @@ export class AgenticChatController implements ChatHandlers { body: `${toolName}`, fileList: undefined, }, + quickSettings, }, collapsedContent: [ { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts index 09fbb20436..66a0a00221 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts @@ -88,3 +88,11 @@ export const DEFAULT_LINUX_STOP_SHORTCUT = 'Meta + ⇧ + ⌫' export const DEFAULT_MACOS_REJECT_SHORTCUT = '⇧ ⌘ R' export const DEFAULT_WINDOW_REJECT_SHORTCUT = 'Ctrl + ⇧ + R' export const DEFAULT_LINUX_REJECT_SHORTCUT = 'Meta + ⇧ + R' + +// Warning Message Constants +export const DESTRUCTIVE_COMMAND_WARNING_MSG = 'WARNING: Potentially destructive command detected:\n\n' +export const MUTATE_COMMAND_WARNING_MSG = 'Mutation command:\n\n' +export const OUT_OF_WORKSPACE_WARNING_MSG = 'Execution out of workspace scope:\n\n' +export const CREDENTIAL_FILE_WARNING_MSG = + 'WARNING: Command involves credential files that require secure permissions:\n\n' +export const BINARY_FILE_WARNING_MSG = 'WARNING: Command involves binary files that require secure permissions:\n\n' diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts index dff24d7fb7..910c50a778 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts @@ -26,6 +26,7 @@ export const BUTTON_RUN_SHELL_COMMAND = 'run-shell-command' export const BUTTON_REJECT_SHELL_COMMAND = 'reject-shell-command' export const BUTTON_REJECT_MCP_TOOL = 'reject-mcp-tool' export const BUTTON_ALLOW_TOOLS = 'allow-tools' +export const BUTTON_TRUST_COMMAND = 'trust-command' export const BUTTON_UNDO_CHANGES = 'undo-changes' export const BUTTON_UNDO_ALL_CHANGES = 'undo-all-changes' export const BUTTON_STOP_SHELL_COMMAND = 'stop-shell-command' diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts index 74662c4e37..b2af7827e4 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts @@ -15,6 +15,15 @@ import { existsSync, statSync } from 'fs' import { parseBaseCommands } from '../utils/commandParser' import { BashCommandEvent, ChatTelemetryEventName } from '../../../shared/telemetry/types' +// Warning message +import { + BINARY_FILE_WARNING_MSG, + CREDENTIAL_FILE_WARNING_MSG, + DESTRUCTIVE_COMMAND_WARNING_MSG, + MUTATE_COMMAND_WARNING_MSG, + OUT_OF_WORKSPACE_WARNING_MSG, +} from '../constants/constants' + export enum CommandCategory { ReadOnly, Mutate, @@ -89,12 +98,6 @@ export const commandCategories = new Map([ ]) export const maxToolResponseSize: number = 1024 * 1024 // 1MB export const lineCount: number = 1024 -export const destructiveCommandWarningMessage = 'WARNING: Potentially destructive command detected:\n\n' -export const mutateCommandWarningMessage = 'Mutation command:\n\n' -export const outOfWorkspaceWarningmessage = 'Execution out of workspace scope:\n\n' -export const credentialFileWarningMessage = - 'WARNING: Command involves credential files that require secure permissions:\n\n' -export const binaryFileWarningMessage = 'WARNING: Command involves binary files that require secure permissions:\n\n' /** * Parameters for executing a command on the system shell. @@ -222,7 +225,7 @@ export class ExecuteBash { // Treat tilde paths as absolute paths (they will be expanded by the shell) return { requiresAcceptance: true, - warning: destructiveCommandWarningMessage, + warning: DESTRUCTIVE_COMMAND_WARNING_MSG, commandCategory: CommandCategory.Destructive, } } else if (!isAbsolute(arg) && params.cwd) { @@ -245,7 +248,7 @@ export class ExecuteBash { this.logging.info(`Detected credential file in command: ${fullPath}`) return { requiresAcceptance: true, - warning: credentialFileWarningMessage, + warning: CREDENTIAL_FILE_WARNING_MSG, commandCategory: CommandCategory.Mutate, } } @@ -255,7 +258,7 @@ export class ExecuteBash { this.logging.info(`Detected binary file in command: ${fullPath}`) return { requiresAcceptance: true, - warning: binaryFileWarningMessage, + warning: BINARY_FILE_WARNING_MSG, commandCategory: CommandCategory.Mutate, } } @@ -272,7 +275,7 @@ export class ExecuteBash { if (!isInWorkspace) { return { requiresAcceptance: true, - warning: outOfWorkspaceWarningmessage, + warning: OUT_OF_WORKSPACE_WARNING_MSG, commandCategory: highestCommandCategory, } } @@ -296,13 +299,13 @@ export class ExecuteBash { case CommandCategory.Destructive: return { requiresAcceptance: true, - warning: destructiveCommandWarningMessage, + warning: DESTRUCTIVE_COMMAND_WARNING_MSG, commandCategory: CommandCategory.Destructive, } case CommandCategory.Mutate: return { requiresAcceptance: true, - warning: mutateCommandWarningMessage, + warning: MUTATE_COMMAND_WARNING_MSG, commandCategory: CommandCategory.Mutate, } case CommandCategory.ReadOnly: @@ -324,7 +327,7 @@ export class ExecuteBash { if (!workspaceFolders || workspaceFolders.length === 0) { return { requiresAcceptance: true, - warning: outOfWorkspaceWarningmessage, + warning: OUT_OF_WORKSPACE_WARNING_MSG, commandCategory: highestCommandCategory, } } @@ -341,7 +344,7 @@ export class ExecuteBash { if (!isInWorkspace) { return { requiresAcceptance: true, - warning: outOfWorkspaceWarningmessage, + warning: OUT_OF_WORKSPACE_WARNING_MSG, commandCategory: highestCommandCategory, } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts index 9ae48ccae3..f1d52e01ee 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts @@ -7,6 +7,7 @@ import { ChildProcess, ChildProcessOptions } from '@aws/lsp-core/out/util/proces import path = require('path') import { dirname } from 'path' import { pathToFileURL } from 'url' +import { OUT_OF_WORKSPACE_WARNING_MSG } from '../constants/constants' export interface GrepSearchParams { path?: string @@ -81,7 +82,8 @@ export class GrepSearch { public async requiresAcceptance(params: GrepSearchParams): Promise { const path = this.getSearchDirectory(params.path) - return { requiresAcceptance: !workspaceUtils.isInWorkspace(getWorkspaceFolderPaths(this.workspace), path) } + const isInWorkspace = workspaceUtils.isInWorkspace(getWorkspaceFolderPaths(this.workspace), path) + return { requiresAcceptance: !isInWorkspace, warning: !isInWorkspace ? OUT_OF_WORKSPACE_WARNING_MSG : '' } } public async invoke(params: GrepSearchParams, token?: CancellationToken): Promise { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts index 01984a310e..e17ba22788 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts @@ -49,6 +49,7 @@ describe('McpEventHandler error handling', () => { }, agent: { getTools: sinon.stub().returns([]), + getBuiltInToolNames: sinon.stub().returns([]), }, lsp: {}, telemetry: { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts index 4596bc0646..49911a9df0 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts @@ -34,6 +34,7 @@ import { URI } from 'vscode-uri' interface PermissionOption { label: string value: string + description: string } export class McpEventHandler { @@ -128,38 +129,42 @@ export class McpEventHandler { // Transform server configs into DetailedListItem objects const activeItems: DetailedListItem[] = [] const disabledItems: DetailedListItem[] = [] - const builtInItems: DetailedListItem[] = [] // Get built-in tools programmatically const allTools = this.#features.agent.getTools({ format: 'bedrock' }) - const mcpToolNames = new Set(mcpManager.getAllTools().map(tool => tool.toolName)) + const builtInToolNames = new Set(this.#features.agent.getBuiltInToolNames()) const builtInTools = allTools - .filter(tool => !mcpToolNames.has(tool.toolSpecification.name)) + .filter(tool => { + return builtInToolNames.has(tool.toolSpecification.name) && tool.toolSpecification.name !== 'fsReplace' + }) .map(tool => ({ name: tool.toolSpecification.name, - description: tool.toolSpecification.description || `${tool.toolSpecification.name} tool`, + description: + this.#getBuiltInToolDescription(tool.toolSpecification.name) || + tool.toolSpecification.description || + `${tool.toolSpecification.name} tool`, })) // Add built-in tools as a server in the active items - // activeItems.push({ - // title: 'Built-in', - // description: `${builtInTools.length} tools`, - // children: [ - // { - // groupName: 'serverInformation', - // children: [ - // { - // title: 'status', - // description: 'ENABLED', - // }, - // { - // title: 'toolcount', - // description: `${builtInTools.length}`, - // }, - // ], - // }, - // ], - // }) + activeItems.push({ + title: 'Built-in', + description: `${builtInTools.length} tools`, + children: [ + { + groupName: 'serverInformation', + children: [ + { + title: 'status', + description: 'ENABLED', + }, + { + title: 'toolcount', + description: `${builtInTools.length}`, + }, + ], + }, + ], + }) Array.from(mcpManagerServerConfigs.entries()).forEach(([serverName, config]) => { const toolsWithPermissions = mcpManager.getAllToolsWithPermissions(serverName) @@ -223,7 +228,7 @@ export class McpEventHandler { // Return the result in the expected format const header = { - title: 'MCP Servers', + title: 'MCP Servers and Built-in Tools', description: "Add MCP servers to extend Q's capabilities.", // only show error on list mcp server page if unable to read mcp.json file status: configLoadErrors @@ -269,6 +274,17 @@ export class McpEventHandler { return this.#getDefaultMcpResponse(params.id) } + async generateEmptyBuiltInToolPermission() { + const personaPath = await this.#getAgentPath() + const perm: MCPServerPermission = { + enabled: true, + toolPerms: {}, + __configPath__: personaPath, + } + + return perm + } + /** * Returns the default MCP servers response */ @@ -276,7 +292,7 @@ export class McpEventHandler { return { id, header: { - title: 'MCP Servers', + title: 'MCP Servers and Built-in Tools', status: {}, description: `Add MCP servers to extend Q's capabilities.`, actions: [], @@ -798,17 +814,23 @@ export class McpEventHandler { if (serverName === 'Built-in') { // Handle Built-in server specially const allTools = this.#features.agent.getTools({ format: 'bedrock' }) - const mcpToolNames = new Set(McpManager.instance.getAllTools().map(tool => tool.toolName)) + const builtInToolNames = new Set(this.#features.agent.getBuiltInToolNames()) + // combine fsWrite and fsReplace into fsWrite const builtInTools = allTools - .filter(tool => !mcpToolNames.has(tool.toolSpecification.name)) + .filter(tool => { + return ( + builtInToolNames.has(tool.toolSpecification.name) && tool.toolSpecification.name !== 'fsReplace' + ) + }) .map(tool => { - // Set default permission based on tool name - const permission = 'alwaysAllow' - + const permission = McpManager.instance.getToolPerm(serverName, tool.toolSpecification.name) return { tool: { toolName: tool.toolSpecification.name, - description: tool.toolSpecification.description || `${tool.toolSpecification.name} tool`, + description: + this.#getBuiltInToolDescription(tool.toolSpecification.name) || + tool.toolSpecification.description || + `${tool.toolSpecification.name} tool`, }, permission, } @@ -821,6 +843,7 @@ export class McpEventHandler { header: { title: serverName, status: serverStatusError || {}, + description: 'TOOLS', actions: [], }, list: [], @@ -1032,17 +1055,23 @@ export class McpEventHandler { // Add tool select options toolsWithPermissions.forEach(item => { const toolName = item.tool.toolName - const currentPermission = this.#getCurrentPermission(item.permission) // For Built-in server, use a special function that doesn't include the 'Deny' option - const permissionOptions = this.#buildPermissionOptions(item.permission) + let permissionOptions = this.#buildPermissionOptions() + + if (serverName === 'Built-in') { + permissionOptions = this.#buildBuiltInPermissionOptions() + } filterOptions.push({ type: 'select', id: `${toolName}`, title: toolName, description: item.tool.description, - placeholder: currentPermission, options: permissionOptions, + ...(toolName === 'fsWrite' + ? { disabled: true, selectTooltip: 'Permission for this tool is not configurable yet' } + : {}), + ...{ value: item.permission, boldTitle: true, mandatory: true, hideMandatoryIcon: true }, }) }) @@ -1079,36 +1108,25 @@ export class McpEventHandler { return editingServerName ? this.#handleEditMcpServer(params) : this.#handleAddNewMcp(params) } - /** - * Gets the current permission setting for a tool - */ - #getCurrentPermission(permission: string): string { - if (permission === McpPermissionType.alwaysAllow) { - return 'Always allow' - } else if (permission === McpPermissionType.deny) { - return 'Deny' - } else { - return 'Ask' - } - } - /** * Builds permission options excluding the current one */ - #buildPermissionOptions(currentPermission: string) { + #buildPermissionOptions() { const permissionOptions: PermissionOption[] = [] - if (currentPermission !== McpPermissionType.alwaysAllow) { - permissionOptions.push({ label: 'Always allow', value: McpPermissionType.alwaysAllow }) - } + permissionOptions.push({ + label: 'Ask', + value: McpPermissionType.ask, + description: 'Ask for your approval each time this tool is run', + }) - if (currentPermission !== McpPermissionType.ask) { - permissionOptions.push({ label: 'Ask', value: McpPermissionType.ask }) - } + permissionOptions.push({ + label: 'Always allow', + value: McpPermissionType.alwaysAllow, + description: 'Always allow this tool to run without asking for approval', + }) - if (currentPermission !== McpPermissionType.deny) { - permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny }) - } + permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny, description: 'Never run this tool' }) return permissionOptions } @@ -1116,33 +1134,51 @@ export class McpEventHandler { /** * Builds permission options for Built-in tools (no 'Disable' option) */ - // #buildBuiltInPermissionOptions(currentPermission: string) { - // const permissionOptions: PermissionOption[] = [] - - // if (currentPermission !== 'alwaysAllow') { - // permissionOptions.push({ - // label: 'Always run', - // value: 'alwaysAllow', - // }) - // } - - // if (currentPermission !== 'ask') { - // permissionOptions.push({ - // label: 'Ask to run', - // value: 'ask', - // }) - // } - - // return permissionOptions - // } + #buildBuiltInPermissionOptions() { + const permissionOptions: PermissionOption[] = [] + + permissionOptions.push({ + label: 'Ask', + value: 'ask', + description: 'Ask for your approval each time this tool is run', + }) + + permissionOptions.push({ + label: 'Always Allow', + value: 'alwaysAllow', + description: 'Always allow this tool to run without asking for approval', + }) + + return permissionOptions + } + + #getBuiltInToolDescription(toolName: string) { + switch (toolName) { + case 'fsRead': + return 'Read the content of files.' + case 'listDirectory': + return 'List the structure of a directory and its subdirectories.' + case 'fileSearch': + return 'Search for files and directories using fuzzy name matching.' + case 'executeBash': + return 'Run shell or powershell commands.\n\nNote: read-only commands are auto-run' + case 'fsWrite': + case 'fsReplace': + return 'Create or edit files.' + case 'codeReview': + return 'Review tool analyzes code for security vulnerabilities, quality issues, and best practices across multiple programming languages.' + default: + return '' + } + } /** * Handles MCP permission change events to update the pending permission config without applying changes */ async #handleMcpPermissionChange(params: McpServerClickParams) { const serverName = params.title - const updatedPermissionConfig = params.optionsValues + const updatedPermissionConfig = params.optionsValues if (!serverName || !updatedPermissionConfig) { return { id: params.id } } @@ -1157,6 +1193,7 @@ export class McpEventHandler { } const mcpServerPermission = await this.#processPermissionUpdates( + serverName, updatedPermissionConfig, serverConfig?.__configPath__ ) @@ -1215,6 +1252,33 @@ export class McpEventHandler { transportType: transportType, languageServerVersion: this.#features.runtime.serverInfo.version, }) + } else { + // it's mean built-in tool, but do another extra check to confirm + if (serverName === 'Built-in') { + let toolName: string[] = [] + let perm: string[] = [] + + for (const [key, val] of Object.entries(permission.toolPerms)) { + toolName.push(key) + perm.push(val) + } + + this.#telemetryController?.emitMCPServerInitializeEvent({ + source: 'updatePermission', + command: 'Built-in', + enabled: true, + numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length, + scope: + permission.__configPath__ === + getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) + ? 'global' + : 'workspace', + transportType: '', + languageServerVersion: this.#features.runtime.serverInfo.version, + toolName: toolName, + permission: perm, + }) + } } // Clear the pending permission config after applying @@ -1354,27 +1418,20 @@ export class McpEventHandler { /** * Processes permission updates from the UI */ - async #processPermissionUpdates(updatedPermissionConfig: any, agentPath: string | undefined) { + async #processPermissionUpdates(serverName: string, updatedPermissionConfig: any, agentPath: string | undefined) { + const builtInToolAgentPath = await this.#getAgentPath() const perm: MCPServerPermission = { enabled: true, toolPerms: {}, - __configPath__: agentPath, + __configPath__: serverName === 'Built-in' ? builtInToolAgentPath : agentPath, } // Process each tool permission setting for (const [key, val] of Object.entries(updatedPermissionConfig)) { if (key === 'scope') continue - // // Get the default permission for this tool from McpManager - // let defaultPermission = McpManager.instance.getToolPerm(serverName, key) - - // // If no default permission is found, use 'alwaysAllow' for Built-in and 'ask' for MCP servers - // if (!defaultPermission) { - // defaultPermission = serverName === 'Built-in' ? 'alwaysAllow' : 'ask' - // } - - // If the value is an empty string (''), skip this tool to preserve its existing permission in the persona file - if (val === '') continue + const currentPerm = McpManager.instance.getToolPerm(serverName, key) + if (val === currentPerm) continue switch (val) { case McpPermissionType.alwaysAllow: perm.toolPerms[key] = McpPermissionType.alwaysAllow diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts index 04f827ff2c..acf2290887 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts @@ -456,7 +456,7 @@ export class McpManager { */ public isToolDisabled(server: string, tool: string): boolean { // built-in tools cannot be disabled - if (server === 'builtIn') { + if (server === 'Built-in') { return false } @@ -493,7 +493,7 @@ export class McpManager { */ public getToolPerm(server: string, tool: string): McpPermissionType { // For built-in tools, check directly without prefix - if (server === 'builtIn') { + if (server === 'Built-in') { return this.agentConfig.allowedTools.includes(tool) ? McpPermissionType.alwaysAllow : McpPermissionType.ask } @@ -937,7 +937,7 @@ export class McpManager { // Process each tool permission for (const [toolName, permission] of Object.entries(perm.toolPerms || {})) { - const toolId = `${serverPrefix}/${toolName}` + const toolId = (unsanitizedServerName !== 'Built-in' ? `${serverPrefix}/` : '') + `${toolName}` if (permission === McpPermissionType.deny) { // For deny: if server is enabled as a whole, we need to switch to individual tools @@ -1077,7 +1077,7 @@ export class McpManager { */ public requiresApproval(server: string, tool: string): boolean { // For built-in tools, check directly without prefix - if (server === 'builtIn') { + if (server === 'Built-in') { return !this.agentConfig.allowedTools.includes(tool) } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts index 45ab34170c..fdf03b7740 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts @@ -10,6 +10,7 @@ import path = require('path') import { QClientCapabilities } from '../../../configuration/qConfigurationServer' import crypto = require('crypto') import { Features } from '@aws/language-server-runtimes/server-interface/server' +import { EXECUTE_BASH } from '../../constants/toolConstants' /** * Load, validate, and parse MCP server configurations from JSON files. @@ -627,7 +628,8 @@ export function enabledMCP(params: InitializeParams | undefined): boolean { export function convertPersonaToAgent( persona: PersonaConfig, mcpServers: Record, - featureAgent: Agent + featureAgent: Agent, + existingAgentConfig: AgentConfig | undefined ): AgentConfig { const agent: AgentConfig = { name: 'default-agent', @@ -694,12 +696,22 @@ export function convertPersonaToAgent( } } - // Add default allowed tools - const writeToolNames = new Set(featureAgent.getBuiltInWriteToolNames()) - const defaultAllowedTools = featureAgent.getBuiltInToolNames().filter(toolName => !writeToolNames.has(toolName)) - for (const toolName of defaultAllowedTools) { - if (!agent.allowedTools.includes(toolName)) { - agent.allowedTools.push(toolName) + // handle permission of built-in tools + // check if agent config exists + const defaultAllowedTools = featureAgent.getBuiltInToolNames().filter(toolName => toolName !== EXECUTE_BASH) + if (!existingAgentConfig) { + // not yet created --> add all defaults tools + for (const toolName of defaultAllowedTools) { + if (!agent.allowedTools.includes(toolName)) { + agent.allowedTools.push(toolName) + } + } + } else { + // only consider tools that are not in existingAgentConfig + for (const toolName of defaultAllowedTools) { + if (!agent.allowedTools.includes(toolName) && !existingAgentConfig.tools.includes(toolName)) { + agent.allowedTools.push(toolName) + } } } @@ -908,7 +920,8 @@ async function migrateConfigToAgent( } // Convert to agent config - const newAgentConfig = convertPersonaToAgent(personaConfig, serverConfigs, agent) + logging.info('Migrating started') + const newAgentConfig = convertPersonaToAgent(personaConfig, serverConfigs, agent, existingAgentConfig) newAgentConfig.includedFiles = ['AmazonQ.md', 'README.md', '.amazonq/rules/**/*.md'] newAgentConfig.resources = [] // Initialize with empty array 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 0303b7fc81..0314bdd754 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 @@ -3,6 +3,7 @@ import { workspaceUtils } from '@aws/lsp-core' import { getWorkspaceFolderPaths } from '@aws/lsp-core/out/util/workspaceUtils' import * as path from 'path' import { CommandCategory } from './executeBash' +import { OUT_OF_WORKSPACE_WARNING_MSG } from '../constants/constants' interface Output { kind: Kind @@ -127,10 +128,13 @@ export async function requiresPathAcceptance( if (logging) { logging.debug('No workspace folders found when checking file acceptance') } - return { requiresAcceptance: true } + return { requiresAcceptance: true, warning: OUT_OF_WORKSPACE_WARNING_MSG } } const isInWorkspace = workspaceUtils.isInWorkspace(workspaceFolders, path) - return { requiresAcceptance: !isInWorkspace } + return { + requiresAcceptance: !isInWorkspace, + warning: !isInWorkspace ? OUT_OF_WORKSPACE_WARNING_MSG : undefined, + } } catch (error) { if (logging) { logging.error(`Error checking file acceptance: ${error}`) diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts index 8e9cb92624..69813e8bd4 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts @@ -195,7 +195,8 @@ export class ChatTelemetryController { cwsprChatTimeBetweenChunks?: number[], agenticCodingMode?: boolean, experimentName?: string, - userVariation?: string + userVariation?: string, + permission?: string[] ) { this.#telemetry.emitMetric({ name: ChatTelemetryEventName.AgencticLoop_InvokeLLM, @@ -216,6 +217,7 @@ export class ChatTelemetryController { modelId, experimentName: experimentName, userVariation: userVariation, + permission: permission?.join(',') ?? '', }, }) } @@ -385,6 +387,8 @@ export class ChatTelemetryController { source?: string transportType?: string languageServerVersion?: string + toolName?: string[] + permission?: string[] }) { this.#telemetry.emitMetric({ name: ChatTelemetryEventName.MCPServerInit, @@ -399,6 +403,8 @@ export class ChatTelemetryController { scope: data?.scope, source: data?.source, transportType: data?.transportType, + toolName: data?.toolName?.join(',') ?? '', + permission: data?.permission?.join(',') ?? '', }, }) }