Path: blob/main/src/vs/platform/agentHost/node/commandAutoApprover.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm';6import * as fs from 'fs';7import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';8import { FileAccess } from '../../../base/common/network.js';9import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js';10import { URI } from '../../../base/common/uri.js';11import { ILogService } from '../../log/common/log.js';1213/** Pattern that detects compound commands (&&, ||, ;, |, backtick, $()) */14const compoundCommandPattern = /&&|\|\||[;|]|`|\$\(/;1516/**17* Result of a command auto-approval check.18* - `approved`: all sub-commands match allow rules and none are denied19* - `denied`: at least one sub-command matches a deny rule20* - `noMatch`: no rule matched — requires user confirmation21*/22export type CommandApprovalResult = 'approved' | 'denied' | 'noMatch';2324interface IAutoApproveRule {25readonly regex: RegExp;26}2728const neverMatchRegex = /(?!.*)/;29const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i;3031/**32* Auto-approves or denies shell commands based on default rules.33*34* Uses tree-sitter to parse compound commands (`foo && bar`) into35* sub-commands that are individually checked against allow/deny lists.36* The default rules mirror the VS Code `chat.tools.terminal.autoApprove`37* setting defaults.38*39* Tree-sitter is initialized eagerly; call {@link initialize} and await the40* result before using {@link shouldAutoApprove} to guarantee synchronous41* parsing. If tree-sitter failed to load, compound commands fall back to42* `noMatch` (user confirmation required).43*/44export class CommandAutoApprover extends Disposable {4546private _allowRules: IAutoApproveRule[] | undefined;47private _denyRules: IAutoApproveRule[] | undefined;48private _parser: Parser | undefined;49private _bashLanguage: Language | undefined;50private _queryClass: typeof Query | undefined;51private readonly _initPromise: Promise<void>;5253constructor(54private readonly _logService: ILogService,55) {56super();57this._initPromise = this._initTreeSitter();58}5960/**61* Returns a promise that resolves once tree-sitter WASM has been loaded.62* Await this before processing any events to guarantee that63* {@link shouldAutoApprove} can parse commands synchronously.64*/65initialize(): Promise<void> {66return this._initPromise;67}6869/**70* Synchronously check whether the given command line should be auto-approved.71* Uses tree-sitter (if loaded) to parse compound commands into sub-commands.72*/73shouldAutoApprove(commandLine: string): CommandApprovalResult {74const trimmed = commandLine.trimStart();75if (trimmed.length === 0) {76return 'approved';77}7879this._ensureRules();8081// Try to extract sub-commands via tree-sitter82const subCommands = this._extractSubCommands(trimmed);83if (subCommands && subCommands.length > 0) {84return this._matchSubCommands(subCommands);85}8687// Fallback: if this looks like a compound command but tree-sitter88// failed to parse it, require user confirmation rather than risking89// auto-approving a dangerous sub-command.90if (compoundCommandPattern.test(trimmed)) {91this._logService.trace('[CommandAutoApprover] Compound command without tree-sitter, requiring confirmation');92return 'noMatch';93}9495// Simple single command — match against rules96return this._matchCommandLine(trimmed);97}9899private _matchSubCommands(subCommands: string[]): CommandApprovalResult {100let allApproved = true;101for (const subCommand of subCommands) {102// Deny transient env var assignments103if (transientEnvVarRegex.test(subCommand)) {104return 'denied';105}106107const result = this._matchSingleCommand(subCommand);108if (result === 'denied') {109return 'denied';110}111if (result !== 'approved') {112allApproved = false;113}114}115return allApproved ? 'approved' : 'noMatch';116}117118private _matchCommandLine(commandLine: string): CommandApprovalResult {119if (transientEnvVarRegex.test(commandLine)) {120return 'denied';121}122return this._matchSingleCommand(commandLine);123}124125private _matchSingleCommand(command: string): CommandApprovalResult {126// Check deny rules first127for (const rule of this._denyRules!) {128if (rule.regex.test(command)) {129return 'denied';130}131}132133// Then check allow rules134for (const rule of this._allowRules!) {135if (rule.regex.test(command)) {136return 'approved';137}138}139140return 'noMatch';141}142143// ---- Tree-sitter --------------------------------------------------------144145private _extractSubCommands(commandLine: string): string[] | undefined {146if (!this._parser || !this._bashLanguage || !this._queryClass) {147return undefined;148}149150try {151this._parser.setLanguage(this._bashLanguage);152const tree = this._parser.parse(commandLine);153if (!tree) {154return undefined;155}156157try {158const query = new this._queryClass(this._bashLanguage, '(command) @command');159const captures: QueryCapture[] = query.captures(tree.rootNode);160const subCommands = captures.map(c => c.node.text);161query.delete();162return subCommands.length > 0 ? subCommands : undefined;163} finally {164tree.delete();165}166} catch (err) {167this._logService.warn('[CommandAutoApprover] Tree-sitter parsing failed', err);168return undefined;169}170}171172private async _initTreeSitter(): Promise<void> {173try {174const { default: TreeSitter } = (await import('@vscode/tree-sitter-wasm'));175176if (this._store.isDisposed) {177return;178}179180// Resolve WASM files from node_modules181const moduleRoot = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@vscode', 'tree-sitter-wasm', 'wasm');182const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath;183184await TreeSitter.Parser.init({185locateFile() {186return wasmPath;187}188});189190if (this._store.isDisposed) {191return;192}193194const parser = new TreeSitter.Parser();195this._register(toDisposable(() => {196try {197parser.delete();198} catch {199// WASM memory may already be freed200}201}));202203// Load bash grammar204const bashWasmPath = URI.joinPath(moduleRoot, 'tree-sitter-bash.wasm').fsPath;205const bashWasm = await fs.promises.readFile(bashWasmPath);206207if (this._store.isDisposed) {208return;209}210211const bashLanguage = await TreeSitter.Language.load(new Uint8Array(bashWasm.buffer, bashWasm.byteOffset, bashWasm.byteLength));212213if (this._store.isDisposed) {214return;215}216217this._parser = parser;218this._bashLanguage = bashLanguage;219this._queryClass = TreeSitter.Query;220this._logService.info('[CommandAutoApprover] Tree-sitter initialized successfully');221} catch (err) {222this._logService.warn('[CommandAutoApprover] Failed to initialize tree-sitter', err);223}224}225226// ---- Rules --------------------------------------------------------------227228private _ensureRules(): void {229if (this._allowRules && this._denyRules) {230return;231}232233const allowRules: IAutoApproveRule[] = [];234const denyRules: IAutoApproveRule[] = [];235236for (const [key, value] of Object.entries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES)) {237const regex = convertAutoApproveEntryToRegex(key);238if (value === true) {239allowRules.push({ regex });240} else if (value === false) {241denyRules.push({ regex });242}243}244245this._allowRules = allowRules;246this._denyRules = denyRules;247}248}249250// ---- Regex conversion -------------------------------------------------------251252function convertAutoApproveEntryToRegex(value: string): RegExp {253// If wrapped in `/`, treat as regex254const regexMatch = value.match(/^\/(?<pattern>.+)\/(?<flags>[dgimsuvy]*)$/);255const regexPattern = regexMatch?.groups?.pattern;256if (regexPattern) {257let flags = regexMatch.groups?.flags;258if (flags) {259flags = flags.replaceAll('g', '');260}261262if (regexPattern === '.*') {263return new RegExp(regexPattern);264}265266try {267const regex = new RegExp(regexPattern, flags || undefined);268if (regExpLeadsToEndlessLoop(regex)) {269return neverMatchRegex;270}271return regex;272} catch {273return neverMatchRegex;274}275}276277if (value === '') {278return neverMatchRegex;279}280281let sanitizedValue: string;282283// Match both path separators if it looks like a path284if (value.includes('/') || value.includes('\\')) {285let pattern = value.replace(/[/\\]/g, '%%PATH_SEP%%');286pattern = escapeRegExpCharacters(pattern);287pattern = pattern.replace(/%%PATH_SEP%%*/g, '[/\\\\]');288sanitizedValue = `^(?:\\.[/\\\\])?${pattern}`;289} else {290sanitizedValue = escapeRegExpCharacters(value);291}292293return new RegExp(`^${sanitizedValue}\\b`);294}295296// ---- Default rules ----------------------------------------------------------297//298// These mirror the VS Code `chat.tools.terminal.autoApprove` setting defaults.299// Kept in sync manually — the actual setting will be wired up later.300301const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly<Record<string, boolean>> = {302// Safe readonly commands303cd: true,304echo: true,305ls: true,306dir: true,307pwd: true,308cat: true,309head: true,310tail: true,311findstr: true,312wc: true,313tr: true,314cut: true,315cmp: true,316which: true,317basename: true,318dirname: true,319realpath: true,320readlink: true,321stat: true,322file: true,323od: true,324du: true,325df: true,326sleep: true,327nl: true,328329grep: true,330331// Safe git sub-commands332'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true,333'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true,334'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false,335'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true,336'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true,337'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true,338'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/': true,339'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/': true,340'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*\\s-(d|D|m|M|-delete|-force)\\b/': false,341342// Docker readonly sub-commands343'/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true,344'/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true,345'/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true,346347// PowerShell348'Get-ChildItem': true,349'Get-Content': true,350'Get-Date': true,351'Get-Random': true,352'Get-Location': true,353'Set-Location': true,354'Write-Host': true,355'Write-Output': true,356'Out-String': true,357'Split-Path': true,358'Join-Path': true,359'Start-Sleep': true,360'Where-Object': true,361'/^Select-[a-z0-9]/i': true,362'/^Measure-[a-z0-9]/i': true,363'/^Compare-[a-z0-9]/i': true,364'/^Format-[a-z0-9]/i': true,365'/^Sort-[a-z0-9]/i': true,366367// Package manager read-only commands368'/^npm\\s+(ls|list|outdated|view|info|show|explain|why|root|prefix|bin|search|doctor|fund|repo|bugs|docs|home|help(-search)?)\\b/': true,369'/^npm\\s+config\\s+(list|get)\\b/': true,370'/^npm\\s+pkg\\s+get\\b/': true,371'/^npm\\s+audit$/': true,372'/^npm\\s+cache\\s+verify\\b/': true,373'/^yarn\\s+(list|outdated|info|why|bin|help|versions)\\b/': true,374'/^yarn\\s+licenses\\b/': true,375'/^yarn\\s+audit\\b(?!.*\\bfix\\b)/': true,376'/^yarn\\s+config\\s+(list|get)\\b/': true,377'/^yarn\\s+cache\\s+dir\\b/': true,378'/^pnpm\\s+(ls|list|outdated|why|root|bin|doctor)\\b/': true,379'/^pnpm\\s+licenses\\b/': true,380'/^pnpm\\s+audit\\b(?!.*\\bfix\\b)/': true,381'/^pnpm\\s+config\\s+(list|get)\\b/': true,382383// Safe lockfile-only installs384'npm ci': true,385'/^yarn\\s+install\\s+--frozen-lockfile\\b/': true,386'/^pnpm\\s+install\\s+--frozen-lockfile\\b/': true,387388// Safe commands with dangerous arg blocking389column: true,390'/^column\\b.*\\s-c\\s+[0-9]{4,}/': false,391date: true,392'/^date\\b.*\\s(-s|--set)\\b/': false,393find: true,394'/^find\\b.*\\s-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/': false,395rg: true,396'/^rg\\b.*\\s(--pre|--hostname-bin)\\b/': false,397sed: true,398'/^sed\\b.*\\s(-[a-zA-Z]*(e|f)[a-zA-Z]*|--expression|--file)\\b/': false,399'/^sed\\b.*s\\/.*\\/.*\\/[ew]/': false,400'/^sed\\b.*;W/': false,401sort: true,402'/^sort\\b.*\\s-(o|S)\\b/': false,403tree: true,404'/^tree\\b.*\\s-o\\b/': false,405'/^xxd$/': true,406'/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true,407408// Dangerous commands409rm: false,410rmdir: false,411del: false,412'Remove-Item': false,413ri: false,414rd: false,415erase: false,416dd: false,417kill: false,418ps: false,419top: false,420'Stop-Process': false,421spps: false,422taskkill: false,423'taskkill.exe': false,424curl: false,425wget: false,426'Invoke-RestMethod': false,427'Invoke-WebRequest': false,428irm: false,429iwr: false,430chmod: false,431chown: false,432'Set-ItemProperty': false,433sp: false,434'Set-Acl': false,435jq: false,436xargs: false,437eval: false,438'Invoke-Expression': false,439iex: false,440};441442443