Merge pull request #272281 from microsoft/tyriar/tree_sitter_subcommand

Use tree sitter for parsing terminal auto approve sub-commands
This commit is contained in:
Daniel Imms 2025-10-22 14:34:36 -07:00 committed by GitHub
commit cc5c66b2e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 289 additions and 789 deletions

View File

@ -1824,6 +1824,7 @@ export default tseslint.config(
// terminalContrib is one extra folder deep
'vs/workbench/contrib/terminalContrib/*/~',
'vscode-notebook-renderer', // Type only import
'@vscode/tree-sitter-wasm', // type import
{
'when': 'hasBrowser',
'pattern': '@xterm/xterm'

8
package-lock.json generated
View File

@ -22,7 +22,7 @@
"@vscode/spdlog": "^0.15.2",
"@vscode/sqlite3": "5.1.8-vscode",
"@vscode/sudo-prompt": "9.3.1",
"@vscode/tree-sitter-wasm": "^0.1.4",
"@vscode/tree-sitter-wasm": "^0.2.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@vscode/windows-mutex": "^0.5.0",
"@vscode/windows-process-tree": "^0.6.0",
@ -3319,9 +3319,9 @@
}
},
"node_modules/@vscode/tree-sitter-wasm": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz",
"integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.2.0.tgz",
"integrity": "sha512-abvLfKwmriqgdS4WrIzFK7mzdPUVqIIW1UWarp2lA8lpOZ1EDPL1snRBKe7g+5R5ri173mNJEuPLnG/NlpMp4w==",
"license": "MIT"
},
"node_modules/@vscode/v8-heap-parser": {

View File

@ -83,7 +83,7 @@
"@vscode/spdlog": "^0.15.2",
"@vscode/sqlite3": "5.1.8-vscode",
"@vscode/sudo-prompt": "9.3.1",
"@vscode/tree-sitter-wasm": "^0.1.4",
"@vscode/tree-sitter-wasm": "^0.2.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@vscode/windows-mutex": "^0.5.0",
"@vscode/windows-process-tree": "^0.6.0",

View File

@ -16,7 +16,7 @@
"@vscode/proxy-agent": "^0.35.0",
"@vscode/ripgrep": "^1.15.13",
"@vscode/spdlog": "^0.15.2",
"@vscode/tree-sitter-wasm": "^0.1.4",
"@vscode/tree-sitter-wasm": "^0.2.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@vscode/windows-process-tree": "^0.6.0",
"@vscode/windows-registry": "^1.1.0",
@ -185,9 +185,9 @@
}
},
"node_modules/@vscode/tree-sitter-wasm": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz",
"integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.2.0.tgz",
"integrity": "sha512-abvLfKwmriqgdS4WrIzFK7mzdPUVqIIW1UWarp2lA8lpOZ1EDPL1snRBKe7g+5R5ri173mNJEuPLnG/NlpMp4w==",
"license": "MIT"
},
"node_modules/@vscode/vscode-languagedetection": {

View File

@ -11,7 +11,7 @@
"@vscode/proxy-agent": "^0.35.0",
"@vscode/ripgrep": "^1.15.13",
"@vscode/spdlog": "^0.15.2",
"@vscode/tree-sitter-wasm": "^0.1.4",
"@vscode/tree-sitter-wasm": "^0.2.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@vscode/windows-process-tree": "^0.6.0",
"@vscode/windows-registry": "^1.1.0",

View File

@ -11,7 +11,7 @@
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@vscode/iconv-lite-umd": "0.7.1",
"@vscode/tree-sitter-wasm": "^0.1.4",
"@vscode/tree-sitter-wasm": "^0.2.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@xterm/addon-clipboard": "^0.2.0-beta.114",
"@xterm/addon-image": "^0.9.0-beta.131",
@ -78,9 +78,9 @@
"license": "MIT"
},
"node_modules/@vscode/tree-sitter-wasm": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz",
"integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.2.0.tgz",
"integrity": "sha512-abvLfKwmriqgdS4WrIzFK7mzdPUVqIIW1UWarp2lA8lpOZ1EDPL1snRBKe7g+5R5ri173mNJEuPLnG/NlpMp4w==",
"license": "MIT"
},
"node_modules/@vscode/vscode-languagedetection": {

View File

@ -6,7 +6,7 @@
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@vscode/iconv-lite-umd": "0.7.1",
"@vscode/tree-sitter-wasm": "^0.1.4",
"@vscode/tree-sitter-wasm": "^0.2.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@xterm/addon-clipboard": "^0.2.0-beta.114",
"@xterm/addon-image": "^0.9.0-beta.131",

View File

@ -54,7 +54,7 @@ export class TreeSitterSyntaxTokenBackend extends AbstractSyntaxTokenBackend {
}
const currentLanguage = this._languageIdObs.read(reader);
const treeSitterLang = this._treeSitterLibraryService.getLanguage(currentLanguage, reader);
const treeSitterLang = this._treeSitterLibraryService.getLanguage(currentLanguage, false, reader);
if (!treeSitterLang) {
return undefined;
}

View File

@ -12,20 +12,46 @@ export const ITreeSitterLibraryService = createDecorator<ITreeSitterLibraryServi
export interface ITreeSitterLibraryService {
readonly _serviceBrand: undefined;
/**
* Gets the tree sitter Parser constructor.
*/
getParserClass(): Promise<typeof Parser>;
supportsLanguage(languageId: string, reader: IReader | undefined): boolean;
getLanguage(languageId: string, reader: IReader | undefined): Language | undefined;
/**
* Return value of null indicates that there are no injection queries for this language.
* @param languageId
* @param reader
* Checks whether a language is supported and available based setting enablement.
* @param languageId The language identifier to check.
* @param reader Optional observable reader.
*/
supportsLanguage(languageId: string, reader: IReader | undefined): boolean;
/**
* Gets the tree sitter Language object synchronously.
* @param languageId The language identifier to retrieve.
* @param ignoreSupportsCheck Whether to ignore the supportsLanguage check.
* @param reader Optional observable reader.
*/
getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined;
/**
* Gets the injection queries for a language. A return value of `null`
* indicates that there are no highlights queries for this language.
* @param languageId The language identifier to retrieve queries for.
* @param reader Optional observable reader.
*/
getInjectionQueries(languageId: string, reader: IReader | undefined): Query | null | undefined;
/**
* Return value of null indicates that there are no highlights queries for this language.
* @param languageId
* @param reader
* Gets the highlighting queries for a language. A return value of `null`
* indicates that there are no highlights queries for this language.
* @param languageId The language identifier to retrieve queries for.
* @param reader Optional observable reader.
*/
getHighlightingQueries(languageId: string, reader: IReader | undefined): Query | null | undefined;
/**
* Creates a one-off custom query for a language.
* @param language The Language to create the query for.
* @param querySource The query source string to compile.
*/
createQuery(language: Language, querySource: string): Promise<Query>;
}

View File

@ -11,30 +11,26 @@ export class StandaloneTreeSitterLibraryService implements ITreeSitterLibrarySer
readonly _serviceBrand: undefined;
getParserClass(): Promise<typeof Parser> {
throw new Error('getParserClass is not implemented in StandaloneTreeSitterLibraryService');
throw new Error('not implemented in StandaloneTreeSitterLibraryService');
}
supportsLanguage(languageId: string, reader: IReader | undefined): boolean {
return false;
}
getLanguage(languageId: string, reader: IReader | undefined): Language | undefined {
getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined {
return undefined;
}
/**
* Return value of null indicates that there are no injection queries for this language.
* @param languageId
* @param reader
*/
getInjectionQueries(languageId: string, reader: IReader | undefined): Query | null | undefined {
return null;
}
/**
* Return value of null indicates that there are no highlights queries for this language.
* @param languageId
* @param reader
*/
getHighlightingQueries(languageId: string, reader: IReader | undefined): Query | null | undefined {
return null;
}
async createQuery(language: Language, querySource: string): Promise<Query> {
throw new Error('not implemented in StandaloneTreeSitterLibraryService');
}
}

View File

@ -11,14 +11,14 @@ export class TestTreeSitterLibraryService implements ITreeSitterLibraryService {
readonly _serviceBrand: undefined;
getParserClass(): Promise<typeof Parser> {
throw new Error('getParserClass is not implemented in TestTreeSitterLibraryService');
throw new Error('not implemented in TestTreeSitterLibraryService');
}
supportsLanguage(languageId: string, reader: IReader | undefined): boolean {
return false;
}
getLanguage(languageId: string, reader: IReader | undefined): Language | undefined {
getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined {
return undefined;
}
@ -29,4 +29,8 @@ export class TestTreeSitterLibraryService implements ITreeSitterLibraryService {
getHighlightingQueries(languageId: string, reader: IReader | undefined): Query | null | undefined {
return null;
}
async createQuery(language: Language, querySource: string): Promise<Query> {
throw new Error('not implemented in TestTreeSitterLibraryService');
}
}

View File

@ -69,7 +69,10 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
// 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';
const canCreateAutoApproval = (
autoApproveResult.subCommandResults.every(e => e.result !== 'denied') &&
autoApproveResult.commandLineResult.result !== 'denied'
);
if (canCreateAutoApproval) {
const unapprovedSubCommands = subCommands.filter((_, index) => {
return autoApproveResult.subCommandResults[index].result !== 'approved';
@ -152,9 +155,7 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
if (
firstSubcommandFirstWord !== commandLine &&
!commandsWithSubcommands.has(commandLine) &&
!commandsWithSubSubCommands.has(commandLine) &&
autoApproveResult.commandLineResult.result !== 'denied' &&
autoApproveResult.subCommandResults.every(e => e.result !== 'denied')
!commandsWithSubSubCommands.has(commandLine)
) {
actions.push({
label: localize('autoApprove.exactCommand', 'Always Allow Exact Command Line'),

View File

@ -1,75 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { OperatingSystem } from '../../../../../base/common/platform.js';
import { isPowerShell } from './runInTerminalHelpers.js';
function createNumberRange(start: number, end: number): string[] {
return Array.from({ length: end - start + 1 }, (_, i) => (start + i).toString());
}
function sortByStringLengthDesc(arr: string[]): string[] {
return [...arr].sort((a, b) => b.length - a.length);
}
// Derived from https://github.com/microsoft/vscode/blob/315b0949786b3807f05cb6acd13bf0029690a052/extensions/terminal-suggest/src/tokens.ts#L14-L18
// Some of these can match the same string, so the order matters.
//
// This isn't perfect, at some point it would be better off moving over to tree sitter for this
// instead of simple string matching.
const shellTypeResetChars = new Map<'sh' | 'zsh' | 'pwsh', string[]>([
['sh', sortByStringLengthDesc([
// Redirection docs (bash) https://www.gnu.org/software/bash/manual/html_node/Redirections.html
...createNumberRange(1, 9).concat('').map(n => `${n}<<<`), // Here strings
...createNumberRange(1, 9).concat('').flatMap(n => createNumberRange(1, 9).map(m => `${n}>&${m}`)), // Redirect stream to stream
...createNumberRange(1, 9).concat('').map(n => `${n}<>`), // Open file descriptor for reading and writing
...createNumberRange(1, 9).concat('&', '').map(n => `${n}>>`),
...createNumberRange(1, 9).concat('&', '').map(n => `${n}>`),
'0<', '||', '&&', '|&', '<<', '&', ';', '{', '>', '<', '|'
])],
['zsh', sortByStringLengthDesc([
// Redirection docs https://zsh.sourceforge.io/Doc/Release/Redirection.html
...createNumberRange(1, 9).concat('').map(n => `${n}<<<`), // Here strings
...createNumberRange(1, 9).concat('').flatMap(n => createNumberRange(1, 9).map(m => `${n}>&${m}`)), // Redirect stream to stream
...createNumberRange(1, 9).concat('').map(n => `${n}<>`), // Open file descriptor for reading and writing
...createNumberRange(1, 9).concat('&', '').map(n => `${n}>>`),
...createNumberRange(1, 9).concat('&', '').map(n => `${n}>`),
'<(', '||', '>|', '>!', '&&', '|&', '&', ';', '{', '<(', '<', '|'
])],
['pwsh', sortByStringLengthDesc([
// Redirection docs: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_redirection?view=powershell-7.5
...createNumberRange(1, 6).concat('*', '').flatMap(n => createNumberRange(1, 6).map(m => `${n}>&${m}`)), // Stream to stream redirection
...createNumberRange(1, 6).concat('*', '').map(n => `${n}>>`),
...createNumberRange(1, 6).concat('*', '').map(n => `${n}>`),
'&&', '<', '|', ';', '!', '&'
])],
]);
export function splitCommandLineIntoSubCommands(commandLine: string, envShell: string, envOS: OperatingSystem): string[] {
let shellType: 'sh' | 'zsh' | 'pwsh';
const envShellWithoutExe = envShell.replace(/\.exe$/, '');
if (isPowerShell(envShell, envOS)) {
shellType = 'pwsh';
} else {
switch (envShellWithoutExe) {
case 'zsh': shellType = 'zsh'; break;
default: shellType = 'sh'; break;
}
}
const subCommands = [commandLine];
const resetChars = shellTypeResetChars.get(shellType);
if (resetChars) {
for (const chars of resetChars) {
for (let i = 0; i < subCommands.length; i++) {
const subCommand = subCommands[i];
if (subCommand.includes(chars)) {
subCommands.splice(i, 1, ...subCommand.split(chars).map(e => e.trim()));
i--;
}
}
}
}
return subCommands.filter(e => e.length > 0);
}

View File

@ -43,10 +43,10 @@ import { RichExecuteStrategy } from '../executeStrategy/richExecuteStrategy.js';
import { getOutput } from '../outputHelpers.js';
import { dedupeRules, generateAutoApproveActions, isFish, isPowerShell, isWindowsPowerShell, isZsh } from '../runInTerminalHelpers.js';
import { RunInTerminalToolTelemetry } from '../runInTerminalToolTelemetry.js';
import { splitCommandLineIntoSubCommands } from '../subCommands.js';
import { ShellIntegrationQuality, ToolTerminalCreator, type IToolTerminal } from '../toolTerminalCreator.js';
import { OutputMonitor } from './monitoring/outputMonitor.js';
import { IPollingResult, OutputMonitorState } from './monitoring/types.js';
import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../treeSitterCommandParser.js';
// #region Tool data
@ -56,10 +56,9 @@ function createPowerShellModelDescription(shell: string): string {
`This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`,
'',
'Command Execution:',
'- Does NOT support multi-line commands',
`- ${isWinPwsh
? 'Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly'
: 'Use && to chain simple commands on one line'}`,
// Even for pwsh 7+ we want to use `;` to chain commands since the tree sitter grammar
// doesn't parse `&&`. See https://github.com/airbus-cert/tree-sitter-powershell/issues/27
'- Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly',
'- Prefer pipelines | for object-based data flow',
'',
'Directory Management:',
@ -259,9 +258,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
private readonly _terminalToolCreator: ToolTerminalCreator;
private readonly _commandSimplifier: CommandSimplifier;
protected readonly _profileFetcher: TerminalProfileFetcher;
private readonly _treeSitterCommandParser: TreeSitterCommandParser;
private readonly _telemetry: RunInTerminalToolTelemetry;
protected readonly _commandLineAutoApprover: CommandLineAutoApprover;
protected readonly _profileFetcher: TerminalProfileFetcher;
protected readonly _sessionTerminalAssociations: Map<string, IToolTerminal> = new Map();
// Immutable window state
@ -293,9 +293,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
this._terminalToolCreator = _instantiationService.createInstance(ToolTerminalCreator);
this._commandSimplifier = _instantiationService.createInstance(CommandSimplifier, this._osBackend);
this._profileFetcher = _instantiationService.createInstance(TerminalProfileFetcher);
this._treeSitterCommandParser = this._instantiationService.createInstance(TreeSitterCommandParser);
this._telemetry = _instantiationService.createInstance(RunInTerminalToolTelemetry);
this._commandLineAutoApprover = this._register(_instantiationService.createInstance(CommandLineAutoApprover));
this._profileFetcher = _instantiationService.createInstance(TerminalProfileFetcher);
// Clear out warning accepted state if the setting is disabled
this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => {
@ -349,94 +350,108 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
// can be reviewed in the terminal channel. It also allows gauging the effective set of
// commands that would be auto approved if it were enabled.
const actualCommand = toolEditedCommand ?? args.command;
const subCommands = splitCommandLineIntoSubCommands(actualCommand, shell, os);
const subCommandResults = subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, shell, os));
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(actualCommand);
const autoApproveReasons: string[] = [
...subCommandResults.map(e => e.reason),
commandLineResult.reason,
];
let isAutoApproved = false;
let isDenied = false;
let autoApproveReason: 'subCommand' | 'commandLine' | undefined;
let autoApproveDefault: boolean | undefined;
let disclaimer: IMarkdownString | undefined;
let customActions: ToolConfirmationAction[] | undefined;
const deniedSubCommandResult = subCommandResults.find(e => e.result === 'denied');
if (deniedSubCommandResult) {
this._logService.info('autoApprove: Sub-command DENIED auto approval');
isDenied = true;
autoApproveDefault = deniedSubCommandResult.rule?.isDefaultRule;
autoApproveReason = 'subCommand';
} else if (commandLineResult.result === 'denied') {
this._logService.info('autoApprove: Command line DENIED auto approval');
isDenied = true;
autoApproveDefault = commandLineResult.rule?.isDefaultRule;
autoApproveReason = 'commandLine';
} else {
if (subCommandResults.every(e => e.result === 'approved')) {
this._logService.info('autoApprove: All sub-commands auto-approved');
autoApproveReason = 'subCommand';
isAutoApproved = true;
autoApproveDefault = subCommandResults.every(e => e.rule?.isDefaultRule);
} else {
this._logService.info('autoApprove: All sub-commands NOT auto-approved');
if (commandLineResult.result === 'approved') {
this._logService.info('autoApprove: Command line auto-approved');
autoApproveReason = 'commandLine';
isAutoApproved = true;
autoApproveDefault = commandLineResult.rule?.isDefaultRule;
} else {
this._logService.info('autoApprove: Command line NOT auto-approved');
}
}
}
// Log detailed auto approval reasoning
for (const reason of autoApproveReasons) {
this._logService.info(`- ${reason}`);
}
// Apply auto approval or force it off depending on enablement/opt-in state
const isAutoApproveEnabled = this._configurationService.getValue(TerminalChatAgentToolsSettingId.EnableAutoApprove) === true;
const isAutoApproveWarningAccepted = this._storageService.getBoolean(TerminalToolConfirmationStorageKeys.TerminalAutoApproveWarningAccepted, StorageScope.APPLICATION, false);
const isAutoApproveAllowed = isAutoApproveEnabled && isAutoApproveWarningAccepted;
if (isAutoApproveEnabled) {
autoApproveInfo = this._createAutoApproveInfo(
isAutoApproved,
isDenied,
let isAutoApproved = false;
let subCommands: string[] | undefined;
const treeSitterLanguage = isPowerShell(shell, os) ? TreeSitterCommandParserLanguage.PowerShell : TreeSitterCommandParserLanguage.Bash;
try {
subCommands = await this._treeSitterCommandParser.extractSubCommands(treeSitterLanguage, actualCommand);
this._logService.info(`RunInTerminalTool: autoApprove: Parsed sub-commands via ${treeSitterLanguage} grammar`, subCommands);
} catch (e) {
console.error(e);
this._logService.info(`RunInTerminalTool: autoApprove: Failed to parse sub-commands via ${treeSitterLanguage} grammar`);
}
if (subCommands) {
const subCommandResults = subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, shell, os));
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(actualCommand);
const autoApproveReasons: string[] = [
...subCommandResults.map(e => e.reason),
commandLineResult.reason,
];
let isDenied = false;
let autoApproveReason: 'subCommand' | 'commandLine' | undefined;
let autoApproveDefault: boolean | undefined;
const deniedSubCommandResult = subCommandResults.find(e => e.result === 'denied');
if (deniedSubCommandResult) {
this._logService.info('RunInTerminalTool: autoApprove: Sub-command DENIED auto approval');
isDenied = true;
autoApproveDefault = deniedSubCommandResult.rule?.isDefaultRule;
autoApproveReason = 'subCommand';
} else if (commandLineResult.result === 'denied') {
this._logService.info('RunInTerminalTool: autoApprove: Command line DENIED auto approval');
isDenied = true;
autoApproveDefault = commandLineResult.rule?.isDefaultRule;
autoApproveReason = 'commandLine';
} else {
if (subCommandResults.every(e => e.result === 'approved')) {
this._logService.info('RunInTerminalTool: autoApprove: All sub-commands auto-approved');
autoApproveReason = 'subCommand';
isAutoApproved = true;
autoApproveDefault = subCommandResults.every(e => e.rule?.isDefaultRule);
} else {
this._logService.info('RunInTerminalTool: autoApprove: All sub-commands NOT auto-approved');
if (commandLineResult.result === 'approved') {
this._logService.info('RunInTerminalTool: autoApprove: Command line auto-approved');
autoApproveReason = 'commandLine';
isAutoApproved = true;
autoApproveDefault = commandLineResult.rule?.isDefaultRule;
} else {
this._logService.info('RunInTerminalTool: autoApprove: Command line NOT auto-approved');
}
}
}
// Log detailed auto approval reasoning
for (const reason of autoApproveReasons) {
this._logService.info(`RunInTerminalTool: autoApprove: - ${reason}`);
}
// Apply auto approval or force it off depending on enablement/opt-in state
if (isAutoApproveEnabled) {
autoApproveInfo = this._createAutoApproveInfo(
isAutoApproved,
isDenied,
autoApproveReason,
subCommandResults,
commandLineResult,
);
} else {
isAutoApproved = false;
}
// Send telemetry about auto approval process
this._telemetry.logPrepare({
terminalToolSessionId,
subCommands,
autoApproveAllowed: !isAutoApproveEnabled ? 'off' : isAutoApproveWarningAccepted ? 'allowed' : 'needsOptIn',
autoApproveResult: isAutoApproved ? 'approved' : isDenied ? 'denied' : 'manual',
autoApproveReason,
subCommandResults,
commandLineResult,
);
} else {
isAutoApproved = false;
}
autoApproveDefault
});
// Send telemetry about auto approval process
this._telemetry.logPrepare({
terminalToolSessionId,
subCommands,
autoApproveAllowed: !isAutoApproveEnabled ? 'off' : isAutoApproveWarningAccepted ? 'allowed' : 'needsOptIn',
autoApproveResult: isAutoApproved ? 'approved' : isDenied ? 'denied' : 'manual',
autoApproveReason,
autoApproveDefault
});
// Add a disclaimer warning about prompt injection for common commands that return
// content from the web
const subCommandsLowerFirstWordOnly = subCommands.map(command => command.split(' ')[0].toLowerCase());
if (!isAutoApproved && (
subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLower.includes(command)) ||
(isPowerShell(shell, os) && subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLowerPwshOnly.includes(command)))
)) {
disclaimer = new MarkdownString(`$(${Codicon.info.id}) ` + localize('runInTerminal.promptInjectionDisclaimer', 'Web content may contain malicious code or attempt prompt injection attacks.'), { supportThemeIcons: true });
}
// Add a disclaimer warning about prompt injection for common commands that return
// content from the web
let disclaimer: IMarkdownString | undefined;
const subCommandsLowerFirstWordOnly = subCommands.map(command => command.split(' ')[0].toLowerCase());
if (!isAutoApproved && (
subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLower.includes(command)) ||
(isPowerShell(shell, os) && subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLowerPwshOnly.includes(command)))
)) {
disclaimer = new MarkdownString(`$(${Codicon.info.id}) ` + localize('runInTerminal.promptInjectionDisclaimer', 'Web content may contain malicious code or attempt prompt injection attacks.'), { supportThemeIcons: true });
}
let customActions: ToolConfirmationAction[] | undefined;
if (!isAutoApproved && isAutoApproveEnabled) {
customActions = generateAutoApproveActions(actualCommand, subCommands, { subCommandResults, commandLineResult });
if (!isAutoApproved && isAutoApproveEnabled) {
customActions = generateAutoApproveActions(actualCommand, subCommands, { subCommandResults, commandLineResult });
}
}
let shellType = basename(shell, '.exe');
@ -985,7 +1000,7 @@ class BackgroundTerminalExecution extends Disposable {
}
}
class TerminalProfileFetcher {
export class TerminalProfileFetcher {
readonly osBackend: Promise<OperatingSystem>;

View File

@ -0,0 +1,56 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { BugIndicatingError } from '../../../../../base/common/errors.js';
import { derived, waitForState } from '../../../../../base/common/observable.js';
import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js';
import type { Language, Parser, Query } from '@vscode/tree-sitter-wasm';
export const enum TreeSitterCommandParserLanguage {
Bash = 'bash',
PowerShell = 'powershell',
}
export class TreeSitterCommandParser {
private readonly _parser: Promise<Parser>;
private readonly _queries: Map<Language, Query> = new Map();
constructor(
@ITreeSitterLibraryService private readonly _treeSitterLibraryService: ITreeSitterLibraryService
) {
this._parser = this._treeSitterLibraryService.getParserClass().then(ParserCtor => new ParserCtor());
}
async extractSubCommands(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise<string[]> {
const parser = await this._parser;
const language = await waitForState(derived(reader => {
return this._treeSitterLibraryService.getLanguage(languageId, true, reader);
}));
parser.setLanguage(language);
const tree = parser.parse(commandLine);
if (!tree) {
throw new BugIndicatingError('Failed to parse tree');
}
const query = await this._getQuery(language);
if (!query) {
throw new BugIndicatingError('Failed to create tree sitter query');
}
const captures = query.captures(tree.rootNode);
const subCommands = captures.map(e => e.node.text);
return subCommands;
}
private async _getQuery(language: Language): Promise<Query> {
let query = this._queries.get(language);
if (!query) {
query = await this._treeSitterLibraryService.createQuery(language, '(command) @command');
this._queries.set(language, query);
}
return query;
}
}

View File

@ -257,24 +257,6 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
// #endregion
// #region Dangerous patterns
//
// Patterns that are considered dangerous as they may lead to inline command execution.
// These will just get blocked outright to be on the safe side, at least until there's a
// real parser https://github.com/microsoft/vscode/issues/261794
// `(command)` many shells execute commands inside parentheses
'/\\(.+\\)/s': { approve: false, matchCommandLine: true },
// `{command}` many shells support execution inside curly braces, additionally this
// typically means the sub-command detection system falls over currently
'/\\{.+\\}/s': { approve: false, matchCommandLine: true },
// `\`command\`` many shells support execution inside backticks
'/`.+`/s': { approve: false, matchCommandLine: true },
// endregion
// #region Dangerous commands
//
// There are countless dangerous commands available on the command line, the defaults

View File

@ -1,469 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { deepStrictEqual } from 'assert';
import { splitCommandLineIntoSubCommands } from '../../browser/subCommands.js';
import { OperatingSystem } from '../../../../../../base/common/platform.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
suite('splitCommandLineIntoSubCommands', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('should split command line into subcommands', () => {
const commandLine = 'echo "Hello World" && ls -la || pwd';
const expectedSubCommands = ['echo "Hello World"', 'ls -la', 'pwd'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
suite('bash/sh shell', () => {
test('should split on logical operators', () => {
const commandLine = 'echo test && ls -la || pwd';
const expectedSubCommands = ['echo test', 'ls -la', 'pwd'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on pipes', () => {
const commandLine = 'ls -la | grep test | wc -l';
const expectedSubCommands = ['ls -la', 'grep test', 'wc -l'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on semicolons', () => {
const commandLine = 'cd /tmp; ls -la; pwd';
const expectedSubCommands = ['cd /tmp', 'ls -la', 'pwd'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on background operator', () => {
const commandLine = 'sleep 5 & echo done';
const expectedSubCommands = ['sleep 5', 'echo done'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on redirection operators', () => {
const commandLine = 'echo test > output.txt && cat output.txt';
const expectedSubCommands = ['echo test', 'output.txt', 'cat output.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on stderr redirection', () => {
const commandLine = 'command 2> error.log && echo success';
const expectedSubCommands = ['command', 'error.log', 'echo success'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on append redirection', () => {
const commandLine = 'echo line1 >> file.txt && echo line2 >> file.txt';
const expectedSubCommands = ['echo line1', 'file.txt', 'echo line2', 'file.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('zsh shell', () => {
test('should split on zsh-specific operators', () => {
const commandLine = 'echo test <<< "input" && ls';
const expectedSubCommands = ['echo test', '"input"', 'ls'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on process substitution', () => {
const commandLine = 'diff <(ls dir1) <(ls dir2)';
const expectedSubCommands = ['diff', 'ls dir1)', 'ls dir2)'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on bidirectional redirection', () => {
const commandLine = 'command <> file.txt && echo done';
const expectedSubCommands = ['command', 'file.txt', 'echo done'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should handle complex zsh command chains', () => {
const commandLine = 'ls | grep test && echo found || echo not found';
const expectedSubCommands = ['ls', 'grep test', 'echo found', 'echo not found'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('PowerShell', () => {
test('should not split on PowerShell logical operators', () => {
const commandLine = 'Get-ChildItem -and Get-Location -or Write-Host "test"';
const expectedSubCommands = ['Get-ChildItem -and Get-Location -or Write-Host "test"'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell pipes', () => {
const commandLine = 'Get-Process | Where-Object Name -eq "notepad" | Stop-Process';
const expectedSubCommands = ['Get-Process', 'Where-Object Name -eq "notepad"', 'Stop-Process'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell redirection', () => {
const commandLine = 'Get-Process > processes.txt && Get-Content processes.txt';
const expectedSubCommands = ['Get-Process', 'processes.txt', 'Get-Content processes.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('edge cases', () => {
test('should return single command when no operators present', () => {
const commandLine = 'echo "hello world"';
const expectedSubCommands = ['echo "hello world"'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should handle empty command', () => {
const commandLine = '';
const expectedSubCommands: string[] = [];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should trim whitespace from subcommands', () => {
const commandLine = 'echo test && ls -la || pwd';
const expectedSubCommands = ['echo test', 'ls -la', 'pwd'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should handle multiple consecutive operators', () => {
const commandLine = 'echo test && && ls';
const expectedSubCommands = ['echo test', 'ls'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should handle unknown shell as sh', () => {
const commandLine = 'echo test && ls -la';
const expectedSubCommands = ['echo test', 'ls -la'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'unknown-shell', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('shell type detection', () => {
test('should detect PowerShell variants', () => {
const commandLine = 'Get-Process ; Get-Location';
const expectedSubCommands = ['Get-Process', 'Get-Location'];
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'powershell', OperatingSystem.Linux), expectedSubCommands);
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows), expectedSubCommands);
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'pwsh', OperatingSystem.Linux), expectedSubCommands);
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows), expectedSubCommands);
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'powershell-preview', OperatingSystem.Linux), expectedSubCommands);
});
test('should detect zsh specifically', () => {
const commandLine = 'echo test <<< input';
const expectedSubCommands = ['echo test', 'input'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should default to sh for other shells', () => {
const commandLine = 'echo test && ls';
const expectedSubCommands = ['echo test', 'ls'];
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux), expectedSubCommands);
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'dash', OperatingSystem.Linux), expectedSubCommands);
deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'fish', OperatingSystem.Linux), expectedSubCommands);
});
});
suite('redirection tests', () => {
suite('output redirection', () => {
test('should split on basic output redirection', () => {
const commandLine = 'echo hello > output.txt';
const expectedSubCommands = ['echo hello', 'output.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on append redirection', () => {
const commandLine = 'echo hello >> output.txt';
const expectedSubCommands = ['echo hello', 'output.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on multiple output redirections', () => {
const commandLine = 'ls > files.txt && cat files.txt > backup.txt';
const expectedSubCommands = ['ls', 'files.txt', 'cat files.txt', 'backup.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on numbered file descriptor redirection', () => {
const commandLine = 'command 1> stdout.txt 2> stderr.txt';
const expectedSubCommands = ['command', 'stdout.txt', 'stderr.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on stderr-only redirection', () => {
const commandLine = 'make 2> errors.log';
const expectedSubCommands = ['make', 'errors.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on all output redirection (&>)', () => {
const commandLine = 'command &> all_output.txt';
const expectedSubCommands = ['command', 'all_output.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('input redirection', () => {
test('should split on input redirection', () => {
const commandLine = 'sort < input.txt';
const expectedSubCommands = ['sort', 'input.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on numbered input redirection', () => {
const commandLine = 'program 0< input.txt';
const expectedSubCommands = ['program', 'input.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on input/output combined', () => {
const commandLine = 'sort < input.txt > output.txt';
const expectedSubCommands = ['sort', 'input.txt', 'output.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('stream redirection', () => {
test('should split on stdout to stderr redirection', () => {
const commandLine = 'echo error 1>&2';
const expectedSubCommands = ['echo error'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on stderr to stdout redirection', () => {
const commandLine = 'command 2>&1';
const expectedSubCommands = ['command'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on stream redirection with numbered descriptors', () => {
const commandLine = 'exec 3>&1 && exec 4>&2';
const expectedSubCommands = ['exec', 'exec'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on multiple stream redirections', () => {
const commandLine = 'command 2>&1 1>&3 3>&2';
const expectedSubCommands = ['command'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('here documents and here strings', () => {
test('should split on here document', () => {
const commandLine = 'cat << EOF && echo done';
const expectedSubCommands = ['cat', 'EOF', 'echo done'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on here string (bash/zsh)', () => {
const commandLine = 'grep pattern <<< "search this text"';
const expectedSubCommands = ['grep pattern', '"search this text"'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on numbered here string', () => {
const commandLine = 'command 3<<< "input data"';
const expectedSubCommands = ['command', '"input data"'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('bidirectional redirection', () => {
test('should split on read/write redirection', () => {
const commandLine = 'dialog <> /dev/tty1';
const expectedSubCommands = ['dialog', '/dev/tty1'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on numbered bidirectional redirection', () => {
const commandLine = 'program 3<> data.file';
const expectedSubCommands = ['program', 'data.file'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('PowerShell redirection', () => {
test('should split on PowerShell output redirection', () => {
const commandLine = 'Get-Process > processes.txt';
const expectedSubCommands = ['Get-Process', 'processes.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell append redirection', () => {
const commandLine = 'Write-Output "log entry" >> log.txt';
const expectedSubCommands = ['Write-Output "log entry"', 'log.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell error stream redirection', () => {
const commandLine = 'Get-Content nonexistent.txt 2> errors.log';
const expectedSubCommands = ['Get-Content nonexistent.txt', 'errors.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell warning stream redirection', () => {
const commandLine = 'Get-Process 3> warnings.log';
const expectedSubCommands = ['Get-Process', 'warnings.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell verbose stream redirection', () => {
const commandLine = 'Get-ChildItem 4> verbose.log';
const expectedSubCommands = ['Get-ChildItem', 'verbose.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell debug stream redirection', () => {
const commandLine = 'Invoke-Command 5> debug.log';
const expectedSubCommands = ['Invoke-Command', 'debug.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell information stream redirection', () => {
const commandLine = 'Write-Information "info" 6> info.log';
const expectedSubCommands = ['Write-Information "info"', 'info.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell all streams redirection', () => {
const commandLine = 'Get-Process *> all_streams.log';
const expectedSubCommands = ['Get-Process', 'all_streams.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on PowerShell stream to stream redirection', () => {
const commandLine = 'Write-Error "error" 2>&1';
const expectedSubCommands = ['Write-Error "error"'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('complex redirection scenarios', () => {
test('should split on command with multiple redirections', () => {
const commandLine = 'command < input.txt > output.txt 2> errors.log';
const expectedSubCommands = ['command', 'input.txt', 'output.txt', 'errors.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on redirection with pipes and logical operators', () => {
const commandLine = 'cat file.txt | grep pattern > results.txt && echo "Found" || echo "Not found" 2> errors.log';
const expectedSubCommands = ['cat file.txt', 'grep pattern', 'results.txt', 'echo "Found"', 'echo "Not found"', 'errors.log'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on chained redirections', () => {
const commandLine = 'echo "step1" > temp.txt && cat temp.txt >> final.txt && rm temp.txt';
const expectedSubCommands = ['echo "step1"', 'temp.txt', 'cat temp.txt', 'final.txt', 'rm temp.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should handle redirection with background processes', () => {
const commandLine = 'long_running_command > output.log 2>&1 & echo "started"';
const expectedSubCommands = ['long_running_command', 'output.log', 'echo "started"'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
suite('zsh-specific redirection', () => {
test('should split on zsh noclobber override', () => {
const commandLine = 'echo "force" >! existing_file.txt';
const expectedSubCommands = ['echo "force"', 'existing_file.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on zsh clobber override', () => {
const commandLine = 'echo "overwrite" >| protected_file.txt';
const expectedSubCommands = ['echo "overwrite"', 'protected_file.txt'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on zsh process substitution for input', () => {
const commandLine = 'diff <(sort file1.txt) <(sort file2.txt)';
const expectedSubCommands = ['diff', 'sort file1.txt)', 'sort file2.txt)'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test('should split on zsh multios', () => {
const commandLine = 'echo "test" | tee >(gzip > file1.gz) >(bzip2 > file1.bz2)';
const expectedSubCommands = ['echo "test"', 'tee', '(gzip', 'file1.gz)', '(bzip2', 'file1.bz2)'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
});
suite('complex command combinations', () => {
test('should handle mixed operators in order', () => {
const commandLine = 'ls | grep test && echo found > result.txt || echo failed';
const expectedSubCommands = ['ls', 'grep test', 'echo found', 'result.txt', 'echo failed'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
test.skip('should handle subshells and braces', () => {
const commandLine = '(cd /tmp && ls) && { echo done; }';
const expectedSubCommands = ['(cd /tmp', 'ls)', '{ echo done', '}'];
const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux);
deepStrictEqual(actualSubCommands, expectedSubCommands);
});
});
});

View File

@ -24,6 +24,17 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../../
import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import { count } from '../../../../../../base/common/strings.js';
import { ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js';
import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js';
import { TreeSitterLibraryService } from '../../../../../services/treeSitter/browser/treeSitterLibraryService.js';
import { FileService } from '../../../../../../platform/files/common/fileService.js';
import { NullLogService } from '../../../../../../platform/log/common/log.js';
import { IFileService } from '../../../../../../platform/files/common/files.js';
import { Schemas } from '../../../../../../base/common/network.js';
// HACK: This test lives in electron-browser/ to ensure this node import works if the test is run in
// web tests https://github.com/microsoft/vscode/issues/272777
// eslint-disable-next-line local/code-layering, local/code-import-patterns
import { DiskFileSystemProvider } from '../../../../../../platform/files/node/diskFileSystemProvider.js';
class TestRunInTerminalTool extends RunInTerminalTool {
protected override _osBackend: Promise<OperatingSystem> = Promise.resolve(OperatingSystem.Windows);
@ -42,6 +53,7 @@ suite('RunInTerminalTool', () => {
let instantiationService: TestInstantiationService;
let configurationService: TestConfigurationService;
let fileService: IFileService;
let storageService: IStorageService;
let terminalServiceDisposeEmitter: Emitter<ITerminalInstance>;
let chatServiceDisposeEmitter: Emitter<{ sessionId: string; reason: 'cleared' }>;
@ -50,13 +62,25 @@ suite('RunInTerminalTool', () => {
setup(() => {
configurationService = new TestConfigurationService();
const logService = new NullLogService();
fileService = store.add(new FileService(logService));
const diskFileSystemProvider = store.add(new DiskFileSystemProvider(logService));
store.add(fileService.registerProvider(Schemas.file, diskFileSystemProvider));
setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true);
terminalServiceDisposeEmitter = new Emitter<ITerminalInstance>();
chatServiceDisposeEmitter = new Emitter<{ sessionId: string; reason: 'cleared' }>();
instantiationService = workbenchInstantiationService({
configurationService: () => configurationService,
fileService: () => fileService,
}, store);
const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService));
treeSitterLibraryService.isTest = true;
instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService);
instantiationService.stub(ILanguageModelToolsService, {
getTools() {
return [];
@ -69,7 +93,7 @@ suite('RunInTerminalTool', () => {
onDidDisposeSession: chatServiceDisposeEmitter.event
});
instantiationService.stub(ITerminalProfileResolverService, {
getDefaultProfile: async () => ({ path: 'pwsh' } as ITerminalProfile)
getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile)
});
storageService = instantiationService.get(IStorageService);
@ -268,32 +292,19 @@ suite('RunInTerminalTool', () => {
'HTTP_PROXY=proxy:8080 wget https://example.com',
'VAR1=value1 VAR2=value2 echo test',
'A=1 B=2 C=3 ./script.sh',
// Dangerous patterns
'echo $(whoami)',
'ls $(pwd)',
'echo `date`',
'cat `which ls`',
'echo ${HOME}',
'ls {a,b,c}',
'echo (Get-Date)',
// Dangerous patterns - multi-line
'echo "{\n}"',
'echo @"\n{\n}"@',
];
suite('auto approved', () => {
for (const command of autoApprovedTestCases) {
test(command.replaceAll('\n', '\\n'), async () => {
assertAutoApproved(await executeToolTest({ command: command }));
assertAutoApproved(await executeToolTest({ command }));
});
}
});
suite('confirmation required', () => {
for (const command of confirmationRequiredTestCases) {
test(command.replaceAll('\n', '\\n'), async () => {
assertConfirmationRequired(await executeToolTest({ command: command }));
assertConfirmationRequired(await executeToolTest({ command }));
});
}
});
@ -319,7 +330,7 @@ suite('RunInTerminalTool', () => {
command: 'rm file.txt',
explanation: 'Remove a file'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
});
test('should require confirmation for commands in deny list even if in allow list', async () => {
@ -332,7 +343,7 @@ suite('RunInTerminalTool', () => {
command: 'rm dangerous-file.txt',
explanation: 'Remove a dangerous file'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
});
test('should handle background commands with confirmation', async () => {
@ -345,7 +356,7 @@ suite('RunInTerminalTool', () => {
explanation: 'Start watching for file changes',
isBackground: true
});
assertConfirmationRequired(result, 'Run `pwsh` command? (background terminal)');
assertConfirmationRequired(result, 'Run `bash` command? (background terminal)');
});
test('should auto-approve background commands in allow list', async () => {
@ -422,18 +433,6 @@ suite('RunInTerminalTool', () => {
assertAutoApproved(result);
});
test('should handle commands with only whitespace', async () => {
setAutoApprove({
echo: true
});
const result = await executeToolTest({
command: ' \t\n ',
explanation: 'Whitespace only command'
});
assertConfirmationRequired(result);
});
test('should handle matchCommandLine: true patterns', async () => {
setAutoApprove({
'/dangerous/': { approve: false, matchCommandLine: true },
@ -504,7 +503,7 @@ suite('RunInTerminalTool', () => {
explanation: 'Build the project'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: 'npm run build' },
'commandLine',
@ -548,7 +547,7 @@ suite('RunInTerminalTool', () => {
explanation: 'Build the project'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
'configure',
]);
@ -560,7 +559,7 @@ suite('RunInTerminalTool', () => {
explanation: 'Install dependencies and build'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: ['npm install', 'npm run build'] },
'commandLine',
@ -578,7 +577,7 @@ suite('RunInTerminalTool', () => {
explanation: 'Run foo command and show first 20 lines'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: 'foo' },
'commandLine',
@ -610,7 +609,7 @@ suite('RunInTerminalTool', () => {
explanation: 'Run multiple piped commands'
});
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: ['foo', 'bar'] },
'commandLine',
@ -921,7 +920,7 @@ suite('RunInTerminalTool', () => {
clearAutoApproveWarningAcceptedState();
assertConfirmationRequired(await executeToolTest({ command: 'echo hello world' }), 'Run `pwsh` command?');
assertConfirmationRequired(await executeToolTest({ command: 'echo hello world' }), 'Run `bash` command?');
});
test('should auto-approve commands when both auto-approve enabled and warning accepted', async () => {
@ -940,7 +939,7 @@ suite('RunInTerminalTool', () => {
});
const result = await executeToolTest({ command: 'echo hello world' });
assertConfirmationRequired(result, 'Run `pwsh` command?');
assertConfirmationRequired(result, 'Run `bash` command?');
});
});
@ -960,66 +959,25 @@ suite('RunInTerminalTool', () => {
});
});
});
suite('TerminalProfileFetcher', () => {
suite('getCopilotProfile', () => {
(isWindows ? test : test.skip)('should return custom profile when configured', async () => {
runInTerminalTool.setBackendOs(OperatingSystem.Windows);
const customProfile = Object.freeze({ path: 'C:\\Windows\\System32\\powershell.exe', args: ['-NoProfile'] });
setConfig(TerminalChatAgentToolsSettingId.TerminalProfileWindows, customProfile);
suite('TerminalProfileFetcher', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
const result = await runInTerminalTool.profileFetcher.getCopilotProfile();
strictEqual(result, customProfile);
});
let instantiationService: TestInstantiationService;
let configurationService: TestConfigurationService;
let testTool: TestRunInTerminalTool;
(isLinux ? test : test.skip)('should fall back to default shell when no custom profile is configured', async () => {
runInTerminalTool.setBackendOs(OperatingSystem.Linux);
setConfig(TerminalChatAgentToolsSettingId.TerminalProfileLinux, null);
setup(() => {
configurationService = new TestConfigurationService();
instantiationService = workbenchInstantiationService({
configurationService: () => configurationService,
}, store);
instantiationService.stub(ILanguageModelToolsService, {
getTools() {
return [];
},
});
instantiationService.stub(ITerminalService, {
onDidDisposeInstance: new Emitter<ITerminalInstance>().event
});
instantiationService.stub(IChatService, {
onDidDisposeSession: new Emitter<{ sessionId: string; reason: 'cleared' }>().event
});
instantiationService.stub(ITerminalProfileResolverService, {
getDefaultProfile: async () => ({ path: 'pwsh' } as ITerminalProfile)
});
testTool = store.add(instantiationService.createInstance(TestRunInTerminalTool));
});
function setConfig(key: string, value: unknown) {
configurationService.setUserConfiguration(key, value);
configurationService.onDidChangeConfigurationEmitter.fire({
affectsConfiguration: () => true,
affectedKeys: new Set([key]),
source: ConfigurationTarget.USER,
change: null!,
});
}
suite('getCopilotProfile', () => {
(isWindows ? test : test.skip)('should return custom profile when configured', async () => {
testTool.setBackendOs(OperatingSystem.Windows);
const customProfile = Object.freeze({ path: 'C:\\Windows\\System32\\powershell.exe', args: ['-NoProfile'] });
setConfig(TerminalChatAgentToolsSettingId.TerminalProfileWindows, customProfile);
const result = await testTool.profileFetcher.getCopilotProfile();
strictEqual(result, customProfile);
});
(isLinux ? test : test.skip)('should fall back to default shell when no custom profile is configured', async () => {
testTool.setBackendOs(OperatingSystem.Linux);
setConfig(TerminalChatAgentToolsSettingId.TerminalProfileLinux, null);
const result = await testTool.profileFetcher.getCopilotProfile();
strictEqual(typeof result, 'object');
strictEqual((result as ITerminalProfile).path, 'pwsh'); // From the mock ITerminalProfileResolverService
const result = await runInTerminalTool.profileFetcher.getCopilotProfile();
strictEqual(typeof result, 'object');
strictEqual((result as ITerminalProfile).path, 'bash');
});
});
});
});

View File

@ -121,8 +121,8 @@ export class TreeSitterLibraryService extends Disposable implements ITreeSitterL
return treeSitter.Parser;
}
getLanguage(languageId: string, reader: IReader | undefined): Language | undefined {
if (!this.supportsLanguage(languageId, reader)) {
getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined {
if (!ignoreSupportsCheck && !this.supportsLanguage(languageId, reader)) {
return undefined;
}
const lang = this._languagesCache.get(languageId).resolvedValue.read(reader);
@ -144,6 +144,11 @@ export class TreeSitterLibraryService extends Disposable implements ITreeSitterL
const query = this._injectionQueries.get({ languageId, kind: 'highlights' }).read(reader);
return query;
}
async createQuery(language: Language, querySource: string): Promise<Query> {
const treeSitter = await this._treeSitterImport.value;
return new treeSitter.Query(language, querySource);
}
}
async function tryReadFile(fileService: IFileService, uri: URI): Promise<IFileContent | undefined> {