Merge pull request #285599 from microsoft/tyriar/276716_history

Add preventShellHistory feature
This commit is contained in:
Daniel Imms
2026-01-02 11:12:11 -08:00
committed by GitHub
10 changed files with 125 additions and 13 deletions
@@ -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"
@@ -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() {
@@ -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")
@@ -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
}
}
@@ -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));
@@ -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<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,
@@ -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)) {
@@ -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 {
@@ -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;
}
}
@@ -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<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);
@@ -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();
@@ -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<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'),
}
};