Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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[] = [];
Expand All @@ -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);

Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, unknown> | undefined) ?? {};
let newValue: Record<string, unknown>;
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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class ToolConfirmationSubPart extends BaseChatToolInvocationSubPart {
AllowWorkspace,
AllowGlobally,
AllowSession,
CustomAction,
}

const buttons: IChatConfirmationButton[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/vs/workbench/contrib/terminal/terminalContribExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ interface IAutoApproveRule {
sourceText: string;
}

export interface ICommandApprovalResultWithReason {
result: ICommandApprovalResult;
reason: string;
}

export type ICommandApprovalResult = 'approved' | 'denied' | 'noMatch';

const neverMatchRegex = /(?!.*)/;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading