Skip to content

Commit ae06bf1

Browse files
authored
Merge pull request #285599 from microsoft/tyriar/276716_history
Add preventShellHistory feature
2 parents 3dc268b + ba79378 commit ae06bf1

File tree

10 files changed

+125
-13
lines changed

10 files changed

+125
-13
lines changed

src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then
6262
builtin return
6363
fi
6464

65+
# Prevent AI-executed commands from polluting shell history
66+
if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then
67+
export HISTCONTROL="ignorespace"
68+
builtin unset VSCODE_PREVENT_SHELL_HISTORY
69+
fi
70+
6571
# Apply EnvironmentVariableCollections if needed
6672
if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then
6773
IFS=':' read -ra ADDR <<< "$VSCODE_ENV_REPLACE"

src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then
9898
builtin return
9999
fi
100100

101+
# Prevent AI-executed commands from polluting shell history
102+
if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then
103+
builtin setopt HIST_IGNORE_SPACE
104+
builtin unset VSCODE_PREVENT_SHELL_HISTORY
105+
fi
106+
101107
# The property (P) and command (E) codes embed values which require escaping.
102108
# Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex.
103109
__vsc_escape_value() {

src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ or exit
2323
set --global VSCODE_SHELL_INTEGRATION 1
2424
set --global __vscode_shell_env_reporting $VSCODE_SHELL_ENV_REPORTING
2525
set -e VSCODE_SHELL_ENV_REPORTING
26+
27+
# Prevent AI-executed commands from polluting shell history
28+
if test "$VSCODE_PREVENT_SHELL_HISTORY" = "1"
29+
set -g fish_private_mode 1
30+
set -e VSCODE_PREVENT_SHELL_HISTORY
31+
end
32+
2633
set -g envVarsToReport
2734
if test -n "$__vscode_shell_env_reporting"
2835
set envVarsToReport (string split "," "$__vscode_shell_env_reporting")

src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,13 @@ function Set-MappedKeyHandlers {
259259

260260
if ($Global:__VSCodeState.HasPSReadLine) {
261261
Set-MappedKeyHandlers
262+
263+
# Prevent AI-executed commands from polluting shell history
264+
if ($env:VSCODE_PREVENT_SHELL_HISTORY -eq "1") {
265+
Set-PSReadLineOption -AddToHistoryHandler {
266+
param([string]$line)
267+
return $false
268+
}
269+
$env:VSCODE_PREVENT_SHELL_HISTORY = $null
270+
}
262271
}

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export function isZsh(envShell: string, os: OperatingSystem): boolean {
3333
return /^zsh$/.test(pathPosix.basename(envShell));
3434
}
3535

36+
export function isBash(envShell: string, os: OperatingSystem): boolean {
37+
if (os === OperatingSystem.Windows) {
38+
return /^bash(?:\.exe)?$/i.test(pathWin32.basename(envShell));
39+
}
40+
return /^bash$/.test(pathPosix.basename(envShell));
41+
}
42+
3643
export function isFish(envShell: string, os: OperatingSystem): boolean {
3744
if (os === OperatingSystem.Windows) {
3845
return /^fish(?:\.exe)?$/i.test(pathWin32.basename(envShell));

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Codicon } from '../../../../../base/common/codicons.js';
99
import { CancellationError } from '../../../../../base/common/errors.js';
1010
import { Event } from '../../../../../base/common/event.js';
1111
import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
12+
import { OperatingSystem } from '../../../../../base/common/platform.js';
1213
import { ThemeIcon } from '../../../../../base/common/themables.js';
1314
import { hasKey, isNumber, isObject, isString } from '../../../../../base/common/types.js';
1415
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
@@ -17,6 +18,8 @@ import { PromptInputState } from '../../../../../platform/terminal/common/capabi
1718
import { ITerminalLogService, ITerminalProfile, TerminalSettingId, type IShellLaunchConfig } from '../../../../../platform/terminal/common/terminal.js';
1819
import { ITerminalService, type ITerminalInstance } from '../../../terminal/browser/terminal.js';
1920
import { getShellIntegrationTimeout } from '../../../terminal/common/terminalEnvironment.js';
21+
import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js';
22+
import { isBash, isFish, isPowerShell, isZsh } from './runInTerminalHelpers.js';
2023

2124
const enum ShellLaunchType {
2225
Unknown = 0,
@@ -50,8 +53,8 @@ export class ToolTerminalCreator {
5053
) {
5154
}
5255

53-
async createTerminal(shellOrProfile: string | ITerminalProfile, token: CancellationToken): Promise<IToolTerminal> {
54-
const instance = await this._createCopilotTerminal(shellOrProfile);
56+
async createTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem, token: CancellationToken): Promise<IToolTerminal> {
57+
const instance = await this._createCopilotTerminal(shellOrProfile, os);
5558
const toolTerminal: IToolTerminal = {
5659
instance,
5760
shellIntegrationQuality: ShellIntegrationQuality.None,
@@ -138,15 +141,32 @@ export class ToolTerminalCreator {
138141
}
139142
}
140143

141-
private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile) {
144+
private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem) {
145+
const shellPath = isString(shellOrProfile) ? shellOrProfile : shellOrProfile.path;
146+
147+
const env: Record<string, string> = {
148+
// Avoid making `git diff` interactive when called from copilot
149+
GIT_PAGER: 'cat',
150+
};
151+
152+
const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true;
153+
if (preventShellHistory) {
154+
// Check if the shell supports history exclusion via shell integration scripts
155+
if (
156+
isBash(shellPath, os) ||
157+
isZsh(shellPath, os) ||
158+
isFish(shellPath, os) ||
159+
isPowerShell(shellPath, os)
160+
) {
161+
env['VSCODE_PREVENT_SHELL_HISTORY'] = '1';
162+
}
163+
}
164+
142165
const config: IShellLaunchConfig = {
143166
icon: ThemeIcon.fromId(Codicon.chatSparkle.id),
144167
hideFromUser: true,
145168
forcePersist: true,
146-
env: {
147-
// Avoid making `git diff` interactive when called from copilot
148-
GIT_PAGER: 'cat',
149-
}
169+
env,
150170
};
151171

152172
if (isString(shellOrProfile)) {

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAutoApproveAnalyzer.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
6767
};
6868
}
6969

70+
const trimmedCommandLine = options.commandLine.trimStart();
71+
7072
let subCommands: string[] | undefined;
7173
try {
72-
subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, options.commandLine);
74+
subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, trimmedCommandLine);
7375
this._log(`Parsed sub-commands via ${options.treeSitterLanguage} grammar`, subCommands);
7476
} catch (e) {
7577
console.error(e);
@@ -88,7 +90,7 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
8890
}
8991

9092
const subCommandResults = await Promise.all(subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, options.shell, options.os, options.cwd, options.chatSessionId)));
91-
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(options.commandLine, options.chatSessionId);
93+
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(trimmedCommandLine, options.chatSessionId);
9294
const autoApproveReasons: string[] = [
9395
...subCommandResults.map(e => e.reason),
9496
commandLineResult.reason,
@@ -169,7 +171,7 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
169171
}
170172

171173
if (!isAutoApproved && isAutoApproveEnabled) {
172-
customActions = generateAutoApproveActions(options.commandLine, subCommands, { subCommandResults, commandLineResult });
174+
customActions = generateAutoApproveActions(trimmedCommandLine, subCommands, { subCommandResults, commandLineResult });
173175
}
174176

175177
return {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
7+
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
8+
import { isBash, isZsh } from '../../runInTerminalHelpers.js';
9+
import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js';
10+
import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js';
11+
12+
/**
13+
* Rewriter that prepends a space to commands to prevent them from being added to shell history for
14+
* certain shells. This depends on $VSCODE_PREVENT_SHELL_HISTORY being handled in shell integration
15+
* scripts to set `HISTCONTROL=ignorespace` (bash) or `HIST_IGNORE_SPACE` (zsh) env vars. The
16+
* prepended space is harmless so we don't try to remove it if shell integration isn't functional.
17+
*/
18+
export class CommandLinePreventHistoryRewriter extends Disposable implements ICommandLineRewriter {
19+
constructor(
20+
@IConfigurationService private readonly _configurationService: IConfigurationService,
21+
) {
22+
super();
23+
}
24+
25+
rewrite(options: ICommandLineRewriterOptions): ICommandLineRewriterResult | undefined {
26+
const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true;
27+
if (!preventShellHistory) {
28+
return undefined;
29+
}
30+
// Only bash and zsh use space prefix to exclude from history
31+
if (isBash(options.shell, options.os) || isZsh(options.shell, options.os)) {
32+
return {
33+
rewritten: ` ${options.commandLine}`,
34+
reasoning: 'Prepended with a space to exclude from shell history'
35+
};
36+
}
37+
return undefined;
38+
}
39+
}

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { IPollingResult, OutputMonitorState } from './monitoring/types.js';
4747
import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js';
4848
import type { ICommandLineRewriter } from './commandLineRewriter/commandLineRewriter.js';
4949
import { CommandLineCdPrefixRewriter } from './commandLineRewriter/commandLineCdPrefixRewriter.js';
50+
import { CommandLinePreventHistoryRewriter } from './commandLineRewriter/commandLinePreventHistoryRewriter.js';
5051
import { CommandLinePwshChainOperatorRewriter } from './commandLineRewriter/commandLinePwshChainOperatorRewriter.js';
5152
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
5253
import { IHistoryService } from '../../../../../services/history/common/history.js';
@@ -312,6 +313,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
312313
this._commandLineRewriters = [
313314
this._register(this._instantiationService.createInstance(CommandLineCdPrefixRewriter)),
314315
this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)),
316+
this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter)),
315317
];
316318
this._commandLineAnalyzers = [
317319
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 {
792794
private async _initBackgroundTerminal(chatSessionId: string, termId: string, terminalToolSessionId: string | undefined, token: CancellationToken): Promise<IToolTerminal> {
793795
this._logService.debug(`RunInTerminalTool: Creating background terminal with ID=${termId}`);
794796
const profile = await this._profileFetcher.getCopilotProfile();
795-
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, token);
797+
const os = await this._osBackend;
798+
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, os, token);
796799
this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance);
797800
this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionId, toolTerminal.instance);
798801
this._registerInputListener(toolTerminal);
@@ -814,7 +817,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
814817
return cachedTerminal;
815818
}
816819
const profile = await this._profileFetcher.getCopilotProfile();
817-
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, token);
820+
const os = await this._osBackend;
821+
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, os, token);
818822
this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance);
819823
this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionId, toolTerminal.instance);
820824
this._registerInputListener(toolTerminal);
@@ -957,7 +961,7 @@ class BackgroundTerminalExecution extends Disposable {
957961
private readonly _xterm: XtermTerminal,
958962
private readonly _commandLine: string,
959963
readonly sessionId: string,
960-
commandId?: string
964+
commandId?: string,
961965
) {
962966
super();
963967

src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const enum TerminalChatAgentToolsSettingId {
2121
ShellIntegrationTimeout = 'chat.tools.terminal.shellIntegrationTimeout',
2222
AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts',
2323
OutputLocation = 'chat.tools.terminal.outputLocation',
24+
PreventShellHistory = 'chat.tools.terminal.preventShellHistory',
2425

2526
TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux',
2627
TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx',
@@ -455,6 +456,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
455456
experiment: {
456457
mode: 'auto'
457458
}
459+
},
460+
[TerminalChatAgentToolsSettingId.PreventShellHistory]: {
461+
type: 'boolean',
462+
default: true,
463+
markdownDescription: [
464+
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:"),
465+
`- \`bash\`: ${localize('preventShellHistory.description.bash', "Sets `HISTCONTROL=ignorespace` and prepends the command with space")}`,
466+
`- \`zsh\`: ${localize('preventShellHistory.description.zsh', "Sets `HIST_IGNORE_SPACE` option and prepends the command with space")}`,
467+
`- \`fish\`: ${localize('preventShellHistory.description.fish', "Sets `fish_private_mode` to prevent any command from entering history")}`,
468+
`- \`pwsh\`: ${localize('preventShellHistory.description.pwsh', "Sets a custom history handler via PSReadLine's `AddToHistoryHandler` to prevent any command from entering history")}`,
469+
].join('\n'),
458470
}
459471
};
460472

0 commit comments

Comments
 (0)