diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts index b3ed501118c61..e59affe1d9fcb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts @@ -4,18 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; +import { asArray } from '../../../../../../base/common/arrays.js'; +import { ErrorNoTelemetry } from '../../../../../../base/common/errors.js'; import { MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { thenIfNotDisposed } from '../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../base/common/network.js'; +import { isObject } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { MarkdownRenderer } from '../../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { localize } from '../../../../../../nls.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { IPreferencesService } from '../../../../../services/preferences/common/preferences.js'; +import { TerminalContribSettingId } from '../../../../terminal/terminalContribExports.js'; import { migrateLegacyTerminalToolSpecificData } from '../../../common/chat.js'; import { ChatContextKeys } from '../../../common/chatContextKeys.js'; import { IChatToolInvocation, type IChatTerminalToolInvocationData, type ILegacyChatTerminalToolInvocationData } from '../../../common/chatService.js'; @@ -29,6 +35,19 @@ import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatMarkdownContentPart, EditorPool } from '../chatMarkdownContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; +export interface ITerminalNewAutoApproveRule { + key: string; + value: boolean | { + approve: boolean; + matchCommandLine?: boolean; + }; +} + +export type TerminalNewAutoApproveButtonData = ( + { type: 'configure' } | + { type: 'newRule'; rule: ITerminalNewAutoApproveRule | ITerminalNewAutoApproveRule[] } +); + export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSubPart { public readonly domNode: HTMLElement; public readonly codeblocks: IChatCodeBlockInfo[] = []; @@ -46,8 +65,10 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub @IKeybindingService keybindingService: IKeybindingService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IPreferencesService private readonly preferencesService: IPreferencesService, ) { super(toolInvocation); @@ -57,7 +78,7 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub terminalData = migrateLegacyTerminalToolSpecificData(terminalData); - const { title, message, disclaimer } = toolInvocation.confirmationMessages; + const { title, message, disclaimer, terminalCustomActions } = toolInvocation.confirmationMessages; const continueLabel = localize('continue', "Continue"); const continueKeybinding = keybindingService.lookupKeybinding(AcceptToolConfirmationActionId)?.getLabel(); const continueTooltip = continueKeybinding ? `${continueLabel} (${continueKeybinding})` : continueLabel; @@ -69,13 +90,14 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub { label: continueLabel, data: true, - tooltip: continueTooltip + tooltip: continueTooltip, + moreActions: terminalCustomActions, }, { label: cancelLabel, data: false, isSecondary: true, - tooltip: cancelTooltip + tooltip: cancelTooltip, }]; const renderedMessage = this._register(this.renderer.render( typeof message === 'string' ? new MarkdownString(message) : message, @@ -142,9 +164,49 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub } ChatContextKeys.Editing.hasToolConfirmation.bindTo(this.contextKeyService).set(true); - this._register(confirmWidget.onDidClick(button => { - toolInvocation.confirmed.complete(button.data); - this.chatWidgetService.getWidgetBySessionId(this.context.element.sessionId)?.focusInput(); + this._register(confirmWidget.onDidClick(async button => { + let doComplete = true; + const data = button.data as TerminalNewAutoApproveButtonData | boolean; + if (typeof data !== 'boolean') { + switch (data.type) { + case 'newRule': { + const newRules = asArray(data.rule); + const inspect = this.configurationService.inspect(TerminalContribSettingId.AutoApprove); + const oldValue = (inspect.user?.value as Record | undefined) ?? {}; + let newValue: Record; + if (isObject(oldValue)) { + newValue = { ...oldValue }; + for (const newRule of newRules) { + newValue[newRule.key] = newRule.value; + } + } else { + this.preferencesService.openSettings({ + jsonEditor: true, + target: ConfigurationTarget.USER, + revealSetting: { + key: TerminalContribSettingId.AutoApprove + }, + }); + throw new ErrorNoTelemetry(`Cannot add new rule, existing setting is unexpected format`); + } + await this.configurationService.updateValue(TerminalContribSettingId.AutoApprove, newValue); + break; + } + case 'configure': { + this.preferencesService.openSettings({ + jsonEditor: false, + target: ConfigurationTarget.USER, + query: `@id:${TerminalContribSettingId.AutoApprove}`, + }); + doComplete = false; + break; + } + } + } + if (doComplete) { + toolInvocation.confirmed.complete(button.data); + this.chatWidgetService.getWidgetBySessionId(this.context.element.sessionId)?.focusInput(); + } })); this._register(confirmWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); toolInvocation.confirmed.p.then(() => { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index 1ac0ef42c8fc9..fa1e210660b8f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -83,6 +83,7 @@ export class ToolConfirmationSubPart extends BaseChatToolInvocationSubPart { AllowWorkspace, AllowGlobally, AllowSession, + CustomAction, } const buttons: IChatConfirmationButton[] = [ diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index ad3f45e18e5f5..4b8de734ae065 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -209,6 +209,13 @@ export interface IToolConfirmationMessages { message: string | IMarkdownString; disclaimer?: string | IMarkdownString; allowAutoConfirm?: boolean; + terminalCustomActions?: IToolConfirmationAction[]; +} + +export interface IToolConfirmationAction { + label: string; + tooltip?: string; + data: any; } export interface IPreparedToolInvocation { diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index d85eb8325573c..e10910b18e694 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -8,7 +8,7 @@ import { TerminalAccessibilityCommandId, defaultTerminalAccessibilityCommandsToS import { terminalAccessibilityConfiguration } from '../terminalContrib/accessibility/common/terminalAccessibilityConfiguration.js'; import { terminalAutoRepliesConfiguration } from '../terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.js'; import { terminalInitialHintConfiguration } from '../terminalContrib/chat/common/terminalInitialHintConfiguration.js'; -import { terminalChatAgentToolsConfiguration } from '../terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; +import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGuide/common/terminalCommandGuideConfiguration.js'; import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js'; import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js'; @@ -33,6 +33,7 @@ export const enum TerminalContribCommandId { export const enum TerminalContribSettingId { StickyScrollEnabled = TerminalStickyScrollSettingId.Enabled, SuggestEnabled = TerminalSuggestSettingId.Enabled, + AutoApprove = TerminalChatAgentToolsSettingId.AutoApprove, } // Export configuration schemes from terminalContrib - this is an exception to the eslint rule since diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/commandLineAutoApprover.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/commandLineAutoApprover.ts index 0dc300ec42c62..601f4e6363200 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/commandLineAutoApprover.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/commandLineAutoApprover.ts @@ -16,6 +16,11 @@ interface IAutoApproveRule { sourceText: string; } +export interface ICommandApprovalResultWithReason { + result: ICommandApprovalResult; + reason: string; +} + export type ICommandApprovalResult = 'approved' | 'denied' | 'noMatch'; const neverMatchRegex = /(?!.*)/; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index b34c8d293961d..c324afe84215d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -23,13 +23,13 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService.js'; import { ILanguageModelsService } from '../../../../chat/common/languageModels.js'; -import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, type IToolConfirmationMessages } from '../../../../chat/common/languageModelToolsService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, type IToolConfirmationAction, type IToolConfirmationMessages } from '../../../../chat/common/languageModelToolsService.js'; import { ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import type { XtermTerminal } from '../../../../terminal/browser/xterm/xtermTerminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; import { getRecommendedToolsOverRunInTerminal } from '../alternativeRecommendation.js'; import { getOutput, pollForOutputAndIdle, promptForMorePolling, racePollingOrPrompt } from '../bufferOutputPolling.js'; -import { CommandLineAutoApprover } from '../commandLineAutoApprover.js'; +import { CommandLineAutoApprover, type ICommandApprovalResultWithReason } from '../commandLineAutoApprover.js'; import { BasicExecuteStrategy } from '../executeStrategy/basicExecuteStrategy.js'; import type { ITerminalExecuteStrategy } from '../executeStrategy/executeStrategy.js'; import { NoneExecuteStrategy } from '../executeStrategy/noneExecuteStrategy.js'; @@ -38,6 +38,7 @@ import { isPowerShell } from '../runInTerminalHelpers.js'; import { extractInlineSubCommands, splitCommandLineIntoSubCommands } from '../subCommands.js'; import { ShellIntegrationQuality, ToolTerminalCreator, type IToolTerminal } from '../toolTerminalCreator.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import type { TerminalNewAutoApproveButtonData } from '../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.js'; const TERMINAL_SESSION_STORAGE_KEY = 'chat.terminalSessions'; @@ -251,12 +252,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { disclaimer = new MarkdownString(`$(${Codicon.info.id}) ` + localize('runInTerminal.promptInjectionDisclaimer', 'Web content may contain malicious code or attempt prompt injection attacks.'), { supportThemeIcons: true }); } + let customActions: IToolConfirmationAction[] | undefined; + if (!isAutoApproved) { + customActions = this._generateAutoApproveActions(args.command, subCommands, { subCommandResults, commandLineResult }); + } + confirmationMessages = isAutoApproved ? undefined : { title: args.isBackground ? localize('runInTerminal.background', "Run command in background terminal") : localize('runInTerminal.foreground', "Run command in terminal"), message: new MarkdownString(args.explanation), disclaimer, + terminalCustomActions: customActions, }; } @@ -758,6 +765,70 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { inputUserSigint: state.inputUserSigint, }); } + + private _generateAutoApproveActions(commandLine: string, subCommands: string[], autoApproveResult: { subCommandResults: ICommandApprovalResultWithReason[]; commandLineResult: ICommandApprovalResultWithReason }): IToolConfirmationAction[] { + const actions: IToolConfirmationAction[] = []; + + // We shouldn't offer configuring rules for commands that are explicitly denied since it + // wouldn't get auto approved with a new rule + const canCreateAutoApproval = autoApproveResult.subCommandResults.some(e => e.result !== 'denied') || autoApproveResult.commandLineResult.result === 'denied'; + if (canCreateAutoApproval) { + // Allow all sub-commands + const subCommandsFirstWordOnly = subCommands.map(command => command.split(' ')[0]); + let subCommandLabel: string; + let subCommandTooltip: string; + if (subCommandsFirstWordOnly.length === 1) { + subCommandLabel = localize('autoApprove.baseCommandSingle', 'Always Allow Command: {0}', subCommandsFirstWordOnly[0]); + subCommandTooltip = localize('autoApprove.baseCommandSingleTooltip', 'Always allow command starting with `{0}` to run without confirmation', subCommandsFirstWordOnly[0]); + } else { + const commandSeparated = subCommandsFirstWordOnly.join(', '); + subCommandLabel = localize('autoApprove.baseCommand', 'Always allow commands: {0}', commandSeparated); + subCommandTooltip = localize('autoApprove.baseCommandTooltip', 'Always allow commands starting with `{0}` to run without confirmation', commandSeparated); + } + actions.push({ + label: subCommandLabel, + tooltip: subCommandTooltip, + data: { + type: 'newRule', + rule: subCommandsFirstWordOnly.map(key => ({ + key, + value: true + })) + } satisfies TerminalNewAutoApproveButtonData + }); + + // Allow exact command line, don't do this if it's just the first sub-command's first + // word + if (subCommandsFirstWordOnly[0] !== commandLine) { + actions.push({ + // Add an extra & since it's treated as a mnemonic + label: localize('autoApprove.exactCommand', 'Always Allow Full Command Line: {0}', commandLine.replaceAll('&&', '&&&')), + tooltip: localize('autoApprove.exactCommandTooltip', 'Always allow this exact command to run without confirmation'), + data: { + type: 'newRule', + rule: { + key: commandLine, + value: { + approve: true, + matchCommandLine: true + } + } + } satisfies TerminalNewAutoApproveButtonData + }); + } + } + + // Always show configure option + actions.push({ + label: localize('autoApprove.configure', 'Configure Auto Approve...'), + tooltip: localize('autoApprove.configureTooltip', 'Open settings to configure terminal command auto approval'), + data: { + type: 'configure' + } satisfies TerminalNewAutoApproveButtonData + }); + + return actions; + } } class BackgroundTerminalExecution extends Disposable { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts index b775a46e5f5db..5be425201139d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts @@ -271,6 +271,114 @@ suite('RunInTerminalTool', () => { }); }); + suite('prepareToolInvocation - custom actions for dropdown', () => { + + test('should generate custom actions for non-auto-approved commands', async () => { + setAutoApprove({ + ls: true, + }); + + const result = await executeToolTest({ + command: 'npm run build', + explanation: 'Build the project' + }); + + assertConfirmationRequired(result, 'Run command in terminal'); + ok(result!.confirmationMessages!.terminalCustomActions, 'Expected custom actions to be defined'); + + const customActions = result!.confirmationMessages!.terminalCustomActions!; + strictEqual(customActions.length, 3, 'Expected 3 custom actions'); + + strictEqual(customActions[0].label, 'Always Allow Command: npm'); + strictEqual(customActions[0].data.type, 'newRule'); + ok(Array.isArray(customActions[0].data.rule), 'Expected rule to be an array'); + + strictEqual(customActions[1].label, 'Always Allow Full Command Line: npm run build'); + strictEqual(customActions[1].data.type, 'newRule'); + ok(!Array.isArray(customActions[1].data.rule), 'Expected rule to be an object'); + + strictEqual(customActions[2].label, 'Configure Auto Approve...'); + strictEqual(customActions[2].data.type, 'configure'); + }); + + test('should generate custom actions for single word commands', async () => { + const result = await executeToolTest({ + command: 'git', + explanation: 'Run git command' + }); + + assertConfirmationRequired(result); + ok(result!.confirmationMessages!.terminalCustomActions, 'Expected custom actions to be defined'); + + const customActions = result!.confirmationMessages!.terminalCustomActions!; + + strictEqual(customActions.length, 2, 'Expected 2 custom actions for single word command'); + + strictEqual(customActions[0].label, 'Always Allow Command: git'); + strictEqual(customActions[0].data.type, 'newRule'); + ok(Array.isArray(customActions[0].data.rule), 'Expected rule to be an array'); + + strictEqual(customActions[1].label, 'Configure Auto Approve...'); + strictEqual(customActions[1].data.type, 'configure'); + }); + + test('should not generate custom actions for auto-approved commands', async () => { + setAutoApprove({ + npm: true + }); + + const result = await executeToolTest({ + command: 'npm run build', + explanation: 'Build the project' + }); + + assertAutoApproved(result); + }); + + test('should only generate configure action for explicitly denied commands', async () => { + setAutoApprove({ + npm: { approve: false } + }); + + const result = await executeToolTest({ + command: 'npm run build', + explanation: 'Build the project' + }); + + assertConfirmationRequired(result, 'Run command in terminal'); + ok(result!.confirmationMessages!.terminalCustomActions, 'Expected custom actions to be defined'); + + const customActions = result!.confirmationMessages!.terminalCustomActions!; + strictEqual(customActions.length, 1, 'Expected only 1 custom action for explicitly denied commands'); + + strictEqual(customActions[0].label, 'Configure Auto Approve...'); + strictEqual(customActions[0].data.type, 'configure'); + }); + + test('should handle && in command line labels with proper mnemonic escaping', async () => { + const result = await executeToolTest({ + command: 'npm install && npm run build', + explanation: 'Install dependencies and build' + }); + + assertConfirmationRequired(result, 'Run command in terminal'); + ok(result!.confirmationMessages!.terminalCustomActions, 'Expected custom actions to be defined'); + + const customActions = result!.confirmationMessages!.terminalCustomActions!; + strictEqual(customActions.length, 3, 'Expected 3 custom actions'); + + strictEqual(customActions[0].label, 'Always allow commands: npm, npm'); + strictEqual(customActions[0].data.type, 'newRule'); + + strictEqual(customActions[1].label, 'Always Allow Full Command Line: npm install &&& npm run build'); + strictEqual(customActions[1].data.type, 'newRule'); + + strictEqual(customActions[2].label, 'Configure Auto Approve...'); + strictEqual(customActions[2].data.type, 'configure'); + }); + + }); + suite('command re-writing', () => { function createRewriteParams(command: string, chatSessionId?: string): IRunInTerminalInputParams { return {