diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh index 8ec742e124f4..87e3a63fe0a4 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh @@ -62,6 +62,12 @@ if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then builtin return fi +# Prevent AI-executed commands from polluting shell history +if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then + export HISTCONTROL="ignorespace" + builtin unset VSCODE_PREVENT_SHELL_HISTORY +fi + # Apply EnvironmentVariableCollections if needed if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_REPLACE" diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh index f32b74f1927b..5389bd95b12d 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh @@ -98,6 +98,12 @@ if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then builtin return fi +# Prevent AI-executed commands from polluting shell history +if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then + builtin setopt HIST_IGNORE_SPACE + builtin unset VSCODE_PREVENT_SHELL_HISTORY +fi + # The property (P) and command (E) codes embed values which require escaping. # Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex. __vsc_escape_value() { diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index 51e0d3a7d6eb..0e0b6798c168 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -23,6 +23,13 @@ or exit set --global VSCODE_SHELL_INTEGRATION 1 set --global __vscode_shell_env_reporting $VSCODE_SHELL_ENV_REPORTING set -e VSCODE_SHELL_ENV_REPORTING + +# Prevent AI-executed commands from polluting shell history +if test "$VSCODE_PREVENT_SHELL_HISTORY" = "1" + set -g fish_private_mode 1 + set -e VSCODE_PREVENT_SHELL_HISTORY +end + set -g envVarsToReport if test -n "$__vscode_shell_env_reporting" set envVarsToReport (string split "," "$__vscode_shell_env_reporting") diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index 3c37defc7b28..e89f6ec24c45 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -259,4 +259,13 @@ function Set-MappedKeyHandlers { if ($Global:__VSCodeState.HasPSReadLine) { Set-MappedKeyHandlers + + # Prevent AI-executed commands from polluting shell history + if ($env:VSCODE_PREVENT_SHELL_HISTORY -eq "1") { + Set-PSReadLineOption -AddToHistoryHandler { + param([string]$line) + return $false + } + $env:VSCODE_PREVENT_SHELL_HISTORY = $null + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts index fc2ed7aa8ab6..81acecc6588f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts @@ -33,6 +33,13 @@ export function isZsh(envShell: string, os: OperatingSystem): boolean { return /^zsh$/.test(pathPosix.basename(envShell)); } +export function isBash(envShell: string, os: OperatingSystem): boolean { + if (os === OperatingSystem.Windows) { + return /^bash(?:\.exe)?$/i.test(pathWin32.basename(envShell)); + } + return /^bash$/.test(pathPosix.basename(envShell)); +} + export function isFish(envShell: string, os: OperatingSystem): boolean { if (os === OperatingSystem.Windows) { return /^fish(?:\.exe)?$/i.test(pathWin32.basename(envShell)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts index b18ccbcf0a60..bb0ea29770e4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts @@ -9,6 +9,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey, isNumber, isObject, isString } from '../../../../../base/common/types.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -17,6 +18,8 @@ import { PromptInputState } from '../../../../../platform/terminal/common/capabi import { ITerminalLogService, ITerminalProfile, TerminalSettingId, type IShellLaunchConfig } from '../../../../../platform/terminal/common/terminal.js'; import { ITerminalService, type ITerminalInstance } from '../../../terminal/browser/terminal.js'; import { getShellIntegrationTimeout } from '../../../terminal/common/terminalEnvironment.js'; +import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js'; +import { isBash, isFish, isPowerShell, isZsh } from './runInTerminalHelpers.js'; const enum ShellLaunchType { Unknown = 0, @@ -50,8 +53,8 @@ export class ToolTerminalCreator { ) { } - async createTerminal(shellOrProfile: string | ITerminalProfile, token: CancellationToken): Promise { - const instance = await this._createCopilotTerminal(shellOrProfile); + async createTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem, token: CancellationToken): Promise { + const instance = await this._createCopilotTerminal(shellOrProfile, os); const toolTerminal: IToolTerminal = { instance, shellIntegrationQuality: ShellIntegrationQuality.None, @@ -138,15 +141,32 @@ export class ToolTerminalCreator { } } - private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile) { + private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem) { + const shellPath = isString(shellOrProfile) ? shellOrProfile : shellOrProfile.path; + + const env: Record = { + // Avoid making `git diff` interactive when called from copilot + GIT_PAGER: 'cat', + }; + + const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true; + if (preventShellHistory) { + // Check if the shell supports history exclusion via shell integration scripts + if ( + isBash(shellPath, os) || + isZsh(shellPath, os) || + isFish(shellPath, os) || + isPowerShell(shellPath, os) + ) { + env['VSCODE_PREVENT_SHELL_HISTORY'] = '1'; + } + } + const config: IShellLaunchConfig = { icon: ThemeIcon.fromId(Codicon.chatSparkle.id), hideFromUser: true, forcePersist: true, - env: { - // Avoid making `git diff` interactive when called from copilot - GIT_PAGER: 'cat', - } + env, }; if (isString(shellOrProfile)) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAutoApproveAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAutoApproveAnalyzer.ts index 29df6031125d..2bcf733727ac 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAutoApproveAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAutoApproveAnalyzer.ts @@ -67,9 +67,11 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma }; } + const trimmedCommandLine = options.commandLine.trimStart(); + let subCommands: string[] | undefined; try { - subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, options.commandLine); + subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, trimmedCommandLine); this._log(`Parsed sub-commands via ${options.treeSitterLanguage} grammar`, subCommands); } catch (e) { console.error(e); @@ -88,7 +90,7 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma } const subCommandResults = await Promise.all(subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, options.shell, options.os, options.cwd, options.chatSessionId))); - const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(options.commandLine, options.chatSessionId); + const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(trimmedCommandLine, options.chatSessionId); const autoApproveReasons: string[] = [ ...subCommandResults.map(e => e.reason), commandLineResult.reason, @@ -169,7 +171,7 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma } if (!isAutoApproved && isAutoApproveEnabled) { - customActions = generateAutoApproveActions(options.commandLine, subCommands, { subCommandResults, commandLineResult }); + customActions = generateAutoApproveActions(trimmedCommandLine, subCommands, { subCommandResults, commandLineResult }); } return { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLinePreventHistoryRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLinePreventHistoryRewriter.ts new file mode 100644 index 000000000000..51452bac4349 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLinePreventHistoryRewriter.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { isBash, isZsh } from '../../runInTerminalHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; +import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js'; + +/** + * Rewriter that prepends a space to commands to prevent them from being added to shell history for + * certain shells. This depends on $VSCODE_PREVENT_SHELL_HISTORY being handled in shell integration + * scripts to set `HISTCONTROL=ignorespace` (bash) or `HIST_IGNORE_SPACE` (zsh) env vars. The + * prepended space is harmless so we don't try to remove it if shell integration isn't functional. + */ +export class CommandLinePreventHistoryRewriter extends Disposable implements ICommandLineRewriter { + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + } + + rewrite(options: ICommandLineRewriterOptions): ICommandLineRewriterResult | undefined { + const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true; + if (!preventShellHistory) { + return undefined; + } + // Only bash and zsh use space prefix to exclude from history + if (isBash(options.shell, options.os) || isZsh(options.shell, options.os)) { + return { + rewritten: ` ${options.commandLine}`, + reasoning: 'Prepended with a space to exclude from shell history' + }; + } + return undefined; + } +} 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 e46f742d4309..a0af0224abef 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -47,6 +47,7 @@ import { IPollingResult, OutputMonitorState } from './monitoring/types.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import type { ICommandLineRewriter } from './commandLineRewriter/commandLineRewriter.js'; import { CommandLineCdPrefixRewriter } from './commandLineRewriter/commandLineCdPrefixRewriter.js'; +import { CommandLinePreventHistoryRewriter } from './commandLineRewriter/commandLinePreventHistoryRewriter.js'; import { CommandLinePwshChainOperatorRewriter } from './commandLineRewriter/commandLinePwshChainOperatorRewriter.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IHistoryService } from '../../../../../services/history/common/history.js'; @@ -312,6 +313,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._commandLineRewriters = [ this._register(this._instantiationService.createInstance(CommandLineCdPrefixRewriter)), this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)), + this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter)), ]; this._commandLineAnalyzers = [ this._register(this._instantiationService.createInstance(CommandLineFileWriteAnalyzer, this._treeSitterCommandParser, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineFileWriteAnalyzer: ${message}`, args))), @@ -792,7 +794,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { private async _initBackgroundTerminal(chatSessionId: string, termId: string, terminalToolSessionId: string | undefined, token: CancellationToken): Promise { this._logService.debug(`RunInTerminalTool: Creating background terminal with ID=${termId}`); const profile = await this._profileFetcher.getCopilotProfile(); - const toolTerminal = await this._terminalToolCreator.createTerminal(profile, token); + const os = await this._osBackend; + const toolTerminal = await this._terminalToolCreator.createTerminal(profile, os, token); this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance); this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionId, toolTerminal.instance); this._registerInputListener(toolTerminal); @@ -814,7 +817,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return cachedTerminal; } const profile = await this._profileFetcher.getCopilotProfile(); - const toolTerminal = await this._terminalToolCreator.createTerminal(profile, token); + const os = await this._osBackend; + const toolTerminal = await this._terminalToolCreator.createTerminal(profile, os, token); this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance); this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionId, toolTerminal.instance); this._registerInputListener(toolTerminal); @@ -957,7 +961,7 @@ class BackgroundTerminalExecution extends Disposable { private readonly _xterm: XtermTerminal, private readonly _commandLine: string, readonly sessionId: string, - commandId?: string + commandId?: string, ) { super(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index fbff39fd80a4..cfa6fc94b1e0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -21,6 +21,7 @@ export const enum TerminalChatAgentToolsSettingId { ShellIntegrationTimeout = 'chat.tools.terminal.shellIntegrationTimeout', AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts', OutputLocation = 'chat.tools.terminal.outputLocation', + PreventShellHistory = 'chat.tools.terminal.preventShellHistory', TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux', TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', @@ -455,6 +456,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary