Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { escapeRegExpCharacters, removeAnsiEscapeCodes } from '../../../../../ba
import { localize } from '../../../../../nls.js';
import type { TerminalNewAutoApproveButtonData } from '../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import type { ToolConfirmationAction } from '../../../chat/common/tools/languageModelToolsService.js';
import type { ICommandApprovalResultWithReason } from './commandLineAutoApprover.js';
import type { ICommandApprovalResultWithReason } from './tools/commandLineAnalyzer/autoApprove/commandLineAutoApprover.js';

export function isPowerShell(envShell: string, os: OperatingSystem): boolean {
if (os === OperatingSystem.Windows) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class RunInTerminalToolTelemetry {
subCommands: string[];
autoApproveAllowed: 'allowed' | 'needsOptIn' | 'off';
autoApproveResult: 'approved' | 'denied' | 'manual';
autoApproveReason: 'subCommand' | 'commandLine' | undefined;
autoApproveReason: 'subCommand' | 'commandLine' | 'npmScript' | undefined;
autoApproveDefault: boolean | undefined;
}) {
const subCommandsSanitized = state.subCommands.map(e => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable } from '../../../../../base/common/lifecycle.js';
import type { OperatingSystem } from '../../../../../base/common/platform.js';
import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../../../base/common/strings.js';
import { isObject } from '../../../../../base/common/types.js';
import { structuralEquals } from '../../../../../base/common/equals.js';
import { ConfigurationTarget, IConfigurationService, type IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js';
import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js';
import { isPowerShell } from './runInTerminalHelpers.js';
import { structuralEquals } from '../../../../../../../../base/common/equals.js';
import { Disposable } from '../../../../../../../../base/common/lifecycle.js';
import type { OperatingSystem } from '../../../../../../../../base/common/platform.js';
import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../../../../../../base/common/strings.js';
import { isObject } from '../../../../../../../../base/common/types.js';
import type { URI } from '../../../../../../../../base/common/uri.js';
import { ConfigurationTarget, IConfigurationService, type IConfigurationValue } from '../../../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../../../platform/instantiation/common/instantiation.js';
import { TerminalChatAgentToolsSettingId } from '../../../../common/terminalChatAgentToolsConfiguration.js';
import { isPowerShell } from '../../../runInTerminalHelpers.js';
import { NpmScriptAutoApprover, type INpmScriptAutoApproveResult } from './npmScriptAutoApprover.js';

export interface IAutoApproveRule {
regex: RegExp;
Expand All @@ -24,6 +27,7 @@ export interface ICommandApprovalResultWithReason {
result: ICommandApprovalResult;
reason: string;
rule?: IAutoApproveRule;
npmScriptResult?: INpmScriptAutoApproveResult;
}

export type ICommandApprovalResult = 'approved' | 'denied' | 'noMatch';
Expand All @@ -36,11 +40,14 @@ export class CommandLineAutoApprover extends Disposable {
private _allowListRules: IAutoApproveRule[] = [];
private _allowListCommandLineRules: IAutoApproveRule[] = [];
private _denyListCommandLineRules: IAutoApproveRule[] = [];
private readonly _npmScriptAutoApprover: NpmScriptAutoApprover;

constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IInstantiationService instantiationService: IInstantiationService,
) {
super();
this._npmScriptAutoApprover = this._register(instantiationService.createInstance(NpmScriptAutoApprover));
this.updateConfiguration();
this._register(this._configurationService.onDidChangeConfiguration(e => {
if (
Expand Down Expand Up @@ -76,7 +83,7 @@ export class CommandLineAutoApprover extends Disposable {
this._denyListCommandLineRules = denyListCommandLineRules;
}

isCommandAutoApproved(command: string, shell: string, os: OperatingSystem): ICommandApprovalResultWithReason {
async isCommandAutoApproved(command: string, shell: string, os: OperatingSystem, cwd: URI | undefined): Promise<ICommandApprovalResultWithReason> {
// Check if the command has a transient environment variable assignment prefix which we
// always deny for now as it can easily lead to execute other commands
if (transientEnvVarRegex.test(command)) {
Expand Down Expand Up @@ -108,6 +115,16 @@ export class CommandLineAutoApprover extends Disposable {
}
}

// Check if this is an npm/yarn/pnpm script defined in package.json
const npmScriptResult = await this._npmScriptAutoApprover.isCommandAutoApproved(command, cwd);
if (npmScriptResult.isAutoApproved) {
return {
result: 'approved',
npmScriptResult,
reason: `Command '${command}' is approved as npm script '${npmScriptResult.scriptName}' is defined in package.json`
};
}

// TODO: LLM-based auto-approval https://github.com/microsoft/vscode/issues/253267

// Fallback is always to require approval
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { MarkdownString, type IMarkdownString } from '../../../../../../../../base/common/htmlContent.js';
import { visit, type JSONVisitor } from '../../../../../../../../base/common/json.js';
import { Disposable } from '../../../../../../../../base/common/lifecycle.js';
import { extUri } from '../../../../../../../../base/common/resources.js';
import { URI } from '../../../../../../../../base/common/uri.js';
import { localize } from '../../../../../../../../nls.js';
import { IConfigurationService } from '../../../../../../../../platform/configuration/common/configuration.js';
import { IFileService } from '../../../../../../../../platform/files/common/files.js';
import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../../../platform/workspace/common/workspace.js';
import { TerminalChatAgentToolsSettingId } from '../../../../common/terminalChatAgentToolsConfiguration.js';

/**
* Regex patterns to match npm/yarn/pnpm run commands and extract the script name.
* Uses named capture groups: 'command' for the package manager, 'scriptName' for the script.
*/
const npmRunPatterns = [
// npm run <script>
// npm run-script <script>
/^(?<command>npm)\s+(?:run(?:-script)?)\s+(?<scriptName>[^\s&|;]+)/i,
// npm test, npm start, npm stop, npm restart (shorthand commands)
// See https://docs.npmjs.com/cli/v10/commands/npm-run-script
/^(?<command>npm)\s+(?<scriptName>test|start|stop|restart)\b/i,
// yarn <script>
// yarn run <script>
/^(?<command>yarn)\s+(?:run\s+)?(?<scriptName>[^\s&|;]+)/i,
// pnpm <script>
// pnpm run <script>
/^(?<command>pnpm)\s+(?:run\s+)?(?<scriptName>[^\s&|;]+)/i,
];

/**
* Yarn built-in commands that should not be treated as script names.
* Note: 'test' is omitted since it's commonly a user script, and 'yarn test'
* is often used to run the 'test' script from package.json.
*/
const yarnBuiltinCommands = new Set([
'add', 'audit', 'autoclean', 'bin', 'cache', 'check', 'config',
'create', 'dedupe', 'dlx', 'exec', 'explain', 'generate-lock-entry',
'global', 'help', 'import', 'info', 'init', 'install', 'licenses',
'link', 'list', 'login', 'logout', 'node', 'outdated', 'owner',
'pack', 'patch', 'patch-commit', 'plugin', 'policies', 'publish',
'rebuild', 'remove', 'run', 'search', 'set', 'stage', 'tag', 'team',
'unlink', 'unplug', 'up', 'upgrade', 'upgrade-interactive',
'version', 'versions', 'why', 'workspace', 'workspaces',
]);

/**
* pnpm built-in commands that should not be treated as script names.
* Note: 'test' is omitted since it's commonly a user script, and 'pnpm test'
* is often used to run the 'test' script from package.json.
*/
const pnpmBuiltinCommands = new Set([
'add', 'audit', 'bin', 'config', 'dedupe', 'deploy', 'dlx', 'doctor',
'env', 'exec', 'fetch', 'import', 'init', 'install', 'install-test',
'licenses', 'link', 'list', 'ln', 'ls', 'outdated', 'pack', 'patch',
'patch-commit', 'patch-remove', 'prune', 'publish', 'rb', 'rebuild',
'remove', 'rm', 'root', 'run', 'server', 'setup', 'store',
'un', 'uninstall', 'unlink', 'up', 'update', 'why',
]);

interface IPackageJsonScripts {
uri: URI;
scripts: Set<string>;
}

export interface INpmScriptAutoApproveResult {
isAutoApproved: boolean;
scriptName?: string;
autoApproveInfo?: IMarkdownString;
}

export class NpmScriptAutoApprover extends Disposable {

constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IFileService private readonly _fileService: IFileService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
) {
super();
}

/**
* Checks if a single command is an npm/yarn/pnpm script that exists in package.json.
* Returns auto-approve result if the command is a valid script.
*/
async isCommandAutoApproved(command: string, cwd: URI | undefined): Promise<INpmScriptAutoApproveResult> {
// Check if the feature is enabled
const isNpmScriptAutoApproveEnabled = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoApproveWorkspaceNpmScripts) === true;
if (!isNpmScriptAutoApproveEnabled) {
return { isAutoApproved: false };
}

// Extract script name from the command
const scriptName = this._extractScriptName(command);
if (!scriptName) {
return { isAutoApproved: false };
}

// Find and parse package.json
const packageJsonScripts = await this._getPackageJsonScripts(cwd);
if (!packageJsonScripts) {
return { isAutoApproved: false };
}

// Check if script exists in package.json
if (!packageJsonScripts.scripts.has(scriptName)) {
return { isAutoApproved: false };
}

// Script exists - auto approve
return {
isAutoApproved: true,
scriptName,
autoApproveInfo: new MarkdownString(
localize('autoApprove.npmScript', 'Auto approved as {0} is defined in package.json', `\`${scriptName}\``)
),
};
}

/**
* Extracts script name from an npm/yarn/pnpm run command.
*/
private _extractScriptName(command: string): string | undefined {
const trimmedCommand = command.trim();

for (const pattern of npmRunPatterns) {
const match = trimmedCommand.match(pattern);
if (match?.groups?.scriptName) {
const { command: pkgManager, scriptName } = match.groups;

// Check if this is a yarn/pnpm shorthand that matches a built-in command
if (pkgManager.toLowerCase() === 'yarn' && yarnBuiltinCommands.has(scriptName.toLowerCase())) {
continue;
}
if (pkgManager.toLowerCase() === 'pnpm' && pnpmBuiltinCommands.has(scriptName.toLowerCase())) {
continue;
}

return scriptName;
}
}

return undefined;
}

/**
* Checks if a URI is within any workspace folder.
*/
private _isWithinWorkspace(uri: URI): boolean {
const workspaceFolders = this._workspaceContextService.getWorkspace().folders;
return workspaceFolders.some((folder: IWorkspaceFolder) => extUri.isEqualOrParent(uri, folder.uri));
}

/**
* Finds and parses package.json to get the scripts section.
* Only looks within the workspace for security.
*/
private async _getPackageJsonScripts(cwd: URI | undefined): Promise<IPackageJsonScripts | undefined> {
// Only look in cwd if it's within the workspace
if (!cwd || !this._isWithinWorkspace(cwd)) {
return undefined;
}

const packageJsonUri = URI.joinPath(cwd, 'package.json');
const scripts = await this._readPackageJsonScripts(packageJsonUri);
if (scripts) {
return { uri: packageJsonUri, scripts };
}

return undefined;
}

/**
* Reads and parses the scripts section from a package.json file.
*/
private async _readPackageJsonScripts(packageJsonUri: URI): Promise<Set<string> | undefined> {
try {
const exists = await this._fileService.exists(packageJsonUri);
if (!exists) {
return undefined;
}

const content = await this._fileService.readFile(packageJsonUri);
const text = content.value.toString();

return this._parsePackageJsonScripts(text);
} catch {
return undefined;
}
}

/**
* Parses the scripts section from package.json content using jsonc-parser.
*/
private _parsePackageJsonScripts(content: string): Set<string> | undefined {
const scripts = new Set<string>();
let inScripts = false;
let level = 0;

const visitor: JSONVisitor = {
onError() {
// Ignore parse errors
},
onObjectBegin() {
level++;
},
onObjectEnd() {
if (inScripts && level === 2) {
inScripts = false;
}
level--;
},
onObjectProperty(property: string) {
if (level === 1 && property === 'scripts') {
inScripts = true;
} else if (inScripts && level === 2) {
scripts.add(property);
}
},
};

visit(content, visitor);

return scripts.size > 0 ? scripts : undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../../chat/browser
import { ChatConfiguration } from '../../../../../chat/common/constants.js';
import type { ToolConfirmationAction } from '../../../../../chat/common/tools/languageModelToolsService.js';
import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js';
import { CommandLineAutoApprover, type IAutoApproveRule, type ICommandApprovalResult, type ICommandApprovalResultWithReason } from '../../commandLineAutoApprover.js';
import { dedupeRules, generateAutoApproveActions, isPowerShell } from '../../runInTerminalHelpers.js';
import type { RunInTerminalToolTelemetry } from '../../runInTerminalToolTelemetry.js';
import { type TreeSitterCommandParser } from '../../treeSitterCommandParser.js';
import type { ICommandLineAnalyzer, ICommandLineAnalyzerOptions, ICommandLineAnalyzerResult } from './commandLineAnalyzer.js';
import { TerminalChatCommandId } from '../../../../chat/browser/terminalChat.js';
import { CommandLineAutoApprover, type IAutoApproveRule, type ICommandApprovalResult, type ICommandApprovalResultWithReason } from './autoApprove/commandLineAutoApprover.js';

const promptInjectionWarningCommandsLower = [
'curl',
Expand Down Expand Up @@ -87,7 +87,7 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
};
}

const subCommandResults = subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, options.shell, options.os));
const subCommandResults = await Promise.all(subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, options.shell, options.os, options.cwd)));
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(options.commandLine);
const autoApproveReasons: string[] = [
...subCommandResults.map(e => e.reason),
Expand Down Expand Up @@ -218,6 +218,12 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
break;
}
case 'subCommand': {
// Check if approval came from npm script
const npmScriptApproval = subCommandResults.find(e => e.npmScriptResult?.isAutoApproved);
if (npmScriptApproval?.npmScriptResult?.autoApproveInfo) {
return npmScriptApproval.npmScriptResult.autoApproveInfo;
}

const uniqueRules = dedupeRules(subCommandResults);
if (uniqueRules.length === 1) {
return new MarkdownString(localize('autoApprove.rule', 'Auto approved by rule {0}', formatRuleLinks(uniqueRules)), mdTrustSettings);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PolicyCategory } from '../../../../../base/common/policy.js';
export const enum TerminalChatAgentToolsSettingId {
EnableAutoApprove = 'chat.tools.terminal.enableAutoApprove',
AutoApprove = 'chat.tools.terminal.autoApprove',
AutoApproveWorkspaceNpmScripts = 'chat.tools.terminal.autoApproveWorkspaceNpmScripts',
IgnoreDefaultAutoApproveRules = 'chat.tools.terminal.ignoreDefaultAutoApproveRules',
BlockDetectedFileWrites = 'chat.tools.terminal.blockDetectedFileWrites',
ShellIntegrationTimeout = 'chat.tools.terminal.shellIntegrationTimeout',
Expand Down Expand Up @@ -355,6 +356,15 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
tags: ['experimental'],
markdownDescription: localize('ignoreDefaultAutoApproveRules.description', "Whether to ignore the built-in default auto-approve rules used by the run in terminal tool as defined in {0}. When this setting is enabled, the run in terminal tool will ignore any rule that comes from the default set but still follow rules defined in the user, remote and workspace settings. Use this setting at your own risk; the default auto-approve rules are designed to protect you against running dangerous commands.", `\`#${TerminalChatAgentToolsSettingId.AutoApprove}#\``),
},
[TerminalChatAgentToolsSettingId.AutoApproveWorkspaceNpmScripts]: {
restricted: true,
type: 'boolean',
// In order to use agent mode the workspace must be trusted, this plus the fact that
// modifying package.json is protected means this is safe to enable by default.
default: true,
tags: ['experimental'],
markdownDescription: localize('autoApproveWorkspaceNpmScripts.description', "Whether to automatically approve npm, yarn, and pnpm run commands when the script is defined in a workspace package.json file. Since the workspace is trusted, scripts defined in package.json are considered safe to run without explicit approval."),
},
[TerminalChatAgentToolsSettingId.BlockDetectedFileWrites]: {
type: 'string',
enum: ['never', 'outsideWorkspace', 'all'],
Expand Down
Loading
Loading