Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -50,8 +53,8 @@ export class ToolTerminalCreator {
) {
}

async createTerminal(shellOrProfile: string | ITerminalProfile, token: CancellationToken): Promise<IToolTerminal> {
const instance = await this._createCopilotTerminal(shellOrProfile);
async createTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem, token: CancellationToken): Promise<IToolTerminal> {
const instance = await this._createCopilotTerminal(shellOrProfile, os);
const toolTerminal: IToolTerminal = {
instance,
shellIntegrationQuality: ShellIntegrationQuality.None,
Expand Down Expand Up @@ -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<string, string> = {
// 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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))),
Expand Down Expand Up @@ -792,7 +794,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
private async _initBackgroundTerminal(chatSessionId: string, termId: string, terminalToolSessionId: string | undefined, token: CancellationToken): Promise<IToolTerminal> {
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);
Expand All @@ -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);
Expand Down Expand Up @@ -957,7 +961,7 @@ class BackgroundTerminalExecution extends Disposable {
private readonly _xterm: XtermTerminal,
private readonly _commandLine: string,
readonly sessionId: string,
commandId?: string
commandId?: string,
) {
super();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -455,6 +456,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
experiment: {
mode: 'auto'
}
},
[TerminalChatAgentToolsSettingId.PreventShellHistory]: {
type: 'boolean',
default: true,
markdownDescription: [
localize('preventShellHistory.description', "Whether to exclude commands run by the terminal tool from the shell history. See below for the supported shells and the method used for each:"),
`- \`bash\`: ${localize('preventShellHistory.description.bash', "Sets `HISTCONTROL=ignorespace` and prepends the command with space")}`,
`- \`zsh\`: ${localize('preventShellHistory.description.zsh', "Sets `HIST_IGNORE_SPACE` option and prepends the command with space")}`,
`- \`fish\`: ${localize('preventShellHistory.description.fish', "Sets `fish_private_mode` to prevent any command from entering history")}`,
`- \`pwsh\`: ${localize('preventShellHistory.description.pwsh', "Sets a custom history handler via PSReadLine's `AddToHistoryHandler` to prevent any command from entering history")}`,
].join('\n'),
}
};

Expand Down
Loading