Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/commandAutoApprover.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm';
7
import * as fs from 'fs';
8
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
9
import { FileAccess } from '../../../base/common/network.js';
10
import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js';
11
import { URI } from '../../../base/common/uri.js';
12
import { ILogService } from '../../log/common/log.js';
13
14
/** Pattern that detects compound commands (&&, ||, ;, |, backtick, $()) */
15
const compoundCommandPattern = /&&|\|\||[;|]|`|\$\(/;
16
17
/**
18
* Result of a command auto-approval check.
19
* - `approved`: all sub-commands match allow rules and none are denied
20
* - `denied`: at least one sub-command matches a deny rule
21
* - `noMatch`: no rule matched — requires user confirmation
22
*/
23
export type CommandApprovalResult = 'approved' | 'denied' | 'noMatch';
24
25
interface IAutoApproveRule {
26
readonly regex: RegExp;
27
}
28
29
const neverMatchRegex = /(?!.*)/;
30
const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i;
31
32
/**
33
* Auto-approves or denies shell commands based on default rules.
34
*
35
* Uses tree-sitter to parse compound commands (`foo && bar`) into
36
* sub-commands that are individually checked against allow/deny lists.
37
* The default rules mirror the VS Code `chat.tools.terminal.autoApprove`
38
* setting defaults.
39
*
40
* Tree-sitter is initialized eagerly; call {@link initialize} and await the
41
* result before using {@link shouldAutoApprove} to guarantee synchronous
42
* parsing. If tree-sitter failed to load, compound commands fall back to
43
* `noMatch` (user confirmation required).
44
*/
45
export class CommandAutoApprover extends Disposable {
46
47
private _allowRules: IAutoApproveRule[] | undefined;
48
private _denyRules: IAutoApproveRule[] | undefined;
49
private _parser: Parser | undefined;
50
private _bashLanguage: Language | undefined;
51
private _queryClass: typeof Query | undefined;
52
private readonly _initPromise: Promise<void>;
53
54
constructor(
55
private readonly _logService: ILogService,
56
) {
57
super();
58
this._initPromise = this._initTreeSitter();
59
}
60
61
/**
62
* Returns a promise that resolves once tree-sitter WASM has been loaded.
63
* Await this before processing any events to guarantee that
64
* {@link shouldAutoApprove} can parse commands synchronously.
65
*/
66
initialize(): Promise<void> {
67
return this._initPromise;
68
}
69
70
/**
71
* Synchronously check whether the given command line should be auto-approved.
72
* Uses tree-sitter (if loaded) to parse compound commands into sub-commands.
73
*/
74
shouldAutoApprove(commandLine: string): CommandApprovalResult {
75
const trimmed = commandLine.trimStart();
76
if (trimmed.length === 0) {
77
return 'approved';
78
}
79
80
this._ensureRules();
81
82
// Try to extract sub-commands via tree-sitter
83
const subCommands = this._extractSubCommands(trimmed);
84
if (subCommands && subCommands.length > 0) {
85
return this._matchSubCommands(subCommands);
86
}
87
88
// Fallback: if this looks like a compound command but tree-sitter
89
// failed to parse it, require user confirmation rather than risking
90
// auto-approving a dangerous sub-command.
91
if (compoundCommandPattern.test(trimmed)) {
92
this._logService.trace('[CommandAutoApprover] Compound command without tree-sitter, requiring confirmation');
93
return 'noMatch';
94
}
95
96
// Simple single command — match against rules
97
return this._matchCommandLine(trimmed);
98
}
99
100
private _matchSubCommands(subCommands: string[]): CommandApprovalResult {
101
let allApproved = true;
102
for (const subCommand of subCommands) {
103
// Deny transient env var assignments
104
if (transientEnvVarRegex.test(subCommand)) {
105
return 'denied';
106
}
107
108
const result = this._matchSingleCommand(subCommand);
109
if (result === 'denied') {
110
return 'denied';
111
}
112
if (result !== 'approved') {
113
allApproved = false;
114
}
115
}
116
return allApproved ? 'approved' : 'noMatch';
117
}
118
119
private _matchCommandLine(commandLine: string): CommandApprovalResult {
120
if (transientEnvVarRegex.test(commandLine)) {
121
return 'denied';
122
}
123
return this._matchSingleCommand(commandLine);
124
}
125
126
private _matchSingleCommand(command: string): CommandApprovalResult {
127
// Check deny rules first
128
for (const rule of this._denyRules!) {
129
if (rule.regex.test(command)) {
130
return 'denied';
131
}
132
}
133
134
// Then check allow rules
135
for (const rule of this._allowRules!) {
136
if (rule.regex.test(command)) {
137
return 'approved';
138
}
139
}
140
141
return 'noMatch';
142
}
143
144
// ---- Tree-sitter --------------------------------------------------------
145
146
private _extractSubCommands(commandLine: string): string[] | undefined {
147
if (!this._parser || !this._bashLanguage || !this._queryClass) {
148
return undefined;
149
}
150
151
try {
152
this._parser.setLanguage(this._bashLanguage);
153
const tree = this._parser.parse(commandLine);
154
if (!tree) {
155
return undefined;
156
}
157
158
try {
159
const query = new this._queryClass(this._bashLanguage, '(command) @command');
160
const captures: QueryCapture[] = query.captures(tree.rootNode);
161
const subCommands = captures.map(c => c.node.text);
162
query.delete();
163
return subCommands.length > 0 ? subCommands : undefined;
164
} finally {
165
tree.delete();
166
}
167
} catch (err) {
168
this._logService.warn('[CommandAutoApprover] Tree-sitter parsing failed', err);
169
return undefined;
170
}
171
}
172
173
private async _initTreeSitter(): Promise<void> {
174
try {
175
const { default: TreeSitter } = (await import('@vscode/tree-sitter-wasm'));
176
177
if (this._store.isDisposed) {
178
return;
179
}
180
181
// Resolve WASM files from node_modules
182
const moduleRoot = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@vscode', 'tree-sitter-wasm', 'wasm');
183
const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath;
184
185
await TreeSitter.Parser.init({
186
locateFile() {
187
return wasmPath;
188
}
189
});
190
191
if (this._store.isDisposed) {
192
return;
193
}
194
195
const parser = new TreeSitter.Parser();
196
this._register(toDisposable(() => {
197
try {
198
parser.delete();
199
} catch {
200
// WASM memory may already be freed
201
}
202
}));
203
204
// Load bash grammar
205
const bashWasmPath = URI.joinPath(moduleRoot, 'tree-sitter-bash.wasm').fsPath;
206
const bashWasm = await fs.promises.readFile(bashWasmPath);
207
208
if (this._store.isDisposed) {
209
return;
210
}
211
212
const bashLanguage = await TreeSitter.Language.load(new Uint8Array(bashWasm.buffer, bashWasm.byteOffset, bashWasm.byteLength));
213
214
if (this._store.isDisposed) {
215
return;
216
}
217
218
this._parser = parser;
219
this._bashLanguage = bashLanguage;
220
this._queryClass = TreeSitter.Query;
221
this._logService.info('[CommandAutoApprover] Tree-sitter initialized successfully');
222
} catch (err) {
223
this._logService.warn('[CommandAutoApprover] Failed to initialize tree-sitter', err);
224
}
225
}
226
227
// ---- Rules --------------------------------------------------------------
228
229
private _ensureRules(): void {
230
if (this._allowRules && this._denyRules) {
231
return;
232
}
233
234
const allowRules: IAutoApproveRule[] = [];
235
const denyRules: IAutoApproveRule[] = [];
236
237
for (const [key, value] of Object.entries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES)) {
238
const regex = convertAutoApproveEntryToRegex(key);
239
if (value === true) {
240
allowRules.push({ regex });
241
} else if (value === false) {
242
denyRules.push({ regex });
243
}
244
}
245
246
this._allowRules = allowRules;
247
this._denyRules = denyRules;
248
}
249
}
250
251
// ---- Regex conversion -------------------------------------------------------
252
253
function convertAutoApproveEntryToRegex(value: string): RegExp {
254
// If wrapped in `/`, treat as regex
255
const regexMatch = value.match(/^\/(?<pattern>.+)\/(?<flags>[dgimsuvy]*)$/);
256
const regexPattern = regexMatch?.groups?.pattern;
257
if (regexPattern) {
258
let flags = regexMatch.groups?.flags;
259
if (flags) {
260
flags = flags.replaceAll('g', '');
261
}
262
263
if (regexPattern === '.*') {
264
return new RegExp(regexPattern);
265
}
266
267
try {
268
const regex = new RegExp(regexPattern, flags || undefined);
269
if (regExpLeadsToEndlessLoop(regex)) {
270
return neverMatchRegex;
271
}
272
return regex;
273
} catch {
274
return neverMatchRegex;
275
}
276
}
277
278
if (value === '') {
279
return neverMatchRegex;
280
}
281
282
let sanitizedValue: string;
283
284
// Match both path separators if it looks like a path
285
if (value.includes('/') || value.includes('\\')) {
286
let pattern = value.replace(/[/\\]/g, '%%PATH_SEP%%');
287
pattern = escapeRegExpCharacters(pattern);
288
pattern = pattern.replace(/%%PATH_SEP%%*/g, '[/\\\\]');
289
sanitizedValue = `^(?:\\.[/\\\\])?${pattern}`;
290
} else {
291
sanitizedValue = escapeRegExpCharacters(value);
292
}
293
294
return new RegExp(`^${sanitizedValue}\\b`);
295
}
296
297
// ---- Default rules ----------------------------------------------------------
298
//
299
// These mirror the VS Code `chat.tools.terminal.autoApprove` setting defaults.
300
// Kept in sync manually — the actual setting will be wired up later.
301
302
const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly<Record<string, boolean>> = {
303
// Safe readonly commands
304
cd: true,
305
echo: true,
306
ls: true,
307
dir: true,
308
pwd: true,
309
cat: true,
310
head: true,
311
tail: true,
312
findstr: true,
313
wc: true,
314
tr: true,
315
cut: true,
316
cmp: true,
317
which: true,
318
basename: true,
319
dirname: true,
320
realpath: true,
321
readlink: true,
322
stat: true,
323
file: true,
324
od: true,
325
du: true,
326
df: true,
327
sleep: true,
328
nl: true,
329
330
grep: true,
331
332
// Safe git sub-commands
333
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true,
334
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true,
335
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false,
336
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true,
337
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true,
338
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true,
339
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/': true,
340
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/': true,
341
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*\\s-(d|D|m|M|-delete|-force)\\b/': false,
342
343
// Docker readonly sub-commands
344
'/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true,
345
'/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true,
346
'/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true,
347
348
// PowerShell
349
'Get-ChildItem': true,
350
'Get-Content': true,
351
'Get-Date': true,
352
'Get-Random': true,
353
'Get-Location': true,
354
'Set-Location': true,
355
'Write-Host': true,
356
'Write-Output': true,
357
'Out-String': true,
358
'Split-Path': true,
359
'Join-Path': true,
360
'Start-Sleep': true,
361
'Where-Object': true,
362
'/^Select-[a-z0-9]/i': true,
363
'/^Measure-[a-z0-9]/i': true,
364
'/^Compare-[a-z0-9]/i': true,
365
'/^Format-[a-z0-9]/i': true,
366
'/^Sort-[a-z0-9]/i': true,
367
368
// Package manager read-only commands
369
'/^npm\\s+(ls|list|outdated|view|info|show|explain|why|root|prefix|bin|search|doctor|fund|repo|bugs|docs|home|help(-search)?)\\b/': true,
370
'/^npm\\s+config\\s+(list|get)\\b/': true,
371
'/^npm\\s+pkg\\s+get\\b/': true,
372
'/^npm\\s+audit$/': true,
373
'/^npm\\s+cache\\s+verify\\b/': true,
374
'/^yarn\\s+(list|outdated|info|why|bin|help|versions)\\b/': true,
375
'/^yarn\\s+licenses\\b/': true,
376
'/^yarn\\s+audit\\b(?!.*\\bfix\\b)/': true,
377
'/^yarn\\s+config\\s+(list|get)\\b/': true,
378
'/^yarn\\s+cache\\s+dir\\b/': true,
379
'/^pnpm\\s+(ls|list|outdated|why|root|bin|doctor)\\b/': true,
380
'/^pnpm\\s+licenses\\b/': true,
381
'/^pnpm\\s+audit\\b(?!.*\\bfix\\b)/': true,
382
'/^pnpm\\s+config\\s+(list|get)\\b/': true,
383
384
// Safe lockfile-only installs
385
'npm ci': true,
386
'/^yarn\\s+install\\s+--frozen-lockfile\\b/': true,
387
'/^pnpm\\s+install\\s+--frozen-lockfile\\b/': true,
388
389
// Safe commands with dangerous arg blocking
390
column: true,
391
'/^column\\b.*\\s-c\\s+[0-9]{4,}/': false,
392
date: true,
393
'/^date\\b.*\\s(-s|--set)\\b/': false,
394
find: true,
395
'/^find\\b.*\\s-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/': false,
396
rg: true,
397
'/^rg\\b.*\\s(--pre|--hostname-bin)\\b/': false,
398
sed: true,
399
'/^sed\\b.*\\s(-[a-zA-Z]*(e|f)[a-zA-Z]*|--expression|--file)\\b/': false,
400
'/^sed\\b.*s\\/.*\\/.*\\/[ew]/': false,
401
'/^sed\\b.*;W/': false,
402
sort: true,
403
'/^sort\\b.*\\s-(o|S)\\b/': false,
404
tree: true,
405
'/^tree\\b.*\\s-o\\b/': false,
406
'/^xxd$/': true,
407
'/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true,
408
409
// Dangerous commands
410
rm: false,
411
rmdir: false,
412
del: false,
413
'Remove-Item': false,
414
ri: false,
415
rd: false,
416
erase: false,
417
dd: false,
418
kill: false,
419
ps: false,
420
top: false,
421
'Stop-Process': false,
422
spps: false,
423
taskkill: false,
424
'taskkill.exe': false,
425
curl: false,
426
wget: false,
427
'Invoke-RestMethod': false,
428
'Invoke-WebRequest': false,
429
irm: false,
430
iwr: false,
431
chmod: false,
432
chown: false,
433
'Set-ItemProperty': false,
434
sp: false,
435
'Set-Acl': false,
436
jq: false,
437
xargs: false,
438
eval: false,
439
'Invoke-Expression': false,
440
iex: false,
441
};
442
443