agentHost: auto-approve terminal commands using treesitter (#306992)

* agentHost: auto-approve terminal commands using treesitter

Co-authored-by: Copilot <copilot@github.com>

* Cleanup

Co-authored-by: Copilot <copilot@github.com>

* Add tests

Co-authored-by: Copilot <copilot@github.com>

* Tests

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Rob Lourens
2026-03-31 21:36:45 -07:00
committed by GitHub
parent b533ee4b6e
commit fbabc5c7ef
8 changed files with 756 additions and 29 deletions

View File

@@ -1623,7 +1623,8 @@ export default tseslint.config(
'tas-client', // node module allowed even in /common/
'@microsoft/1ds-core-js', // node module allowed even in /common/
'@microsoft/1ds-post-js', // node module allowed even in /common/
'@xterm/headless' // node module allowed even in /common/
'@xterm/headless', // node module allowed even in /common/
'@vscode/tree-sitter-wasm' // used by agentHost for command auto-approval
]
},
{

View File

@@ -168,8 +168,19 @@ export class AgentService extends Disposable implements IAgentService {
if (!provider) {
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
}
// Ensure the command auto-approver is ready before any session events
// can arrive. This makes shell command auto-approval fully synchronous.
// Safe to run in parallel with createSession since no events flow until
// sendMessage() is called.
this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`);
const [, session] = await Promise.all([
this._sideEffects.initialize(),
provider.createSession(config),
]);
this._logService.trace(`[AgentService] createSession: initialization complete`);
this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`);
const session = await provider.createSession(config);
this._sessionToProvider.set(session.toString(), provider.id);
this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);

View File

@@ -6,10 +6,11 @@
import { match as globMatch } from '../../../base/common/glob.js';
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
import { autorun, IObservable } from '../../../base/common/observable.js';
import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { ILogService } from '../../log/common/log.js';
import { IAgent, IAgentAttachment } from '../common/agentService.js';
import { IAgent, IAgentAttachment, IAgentProgressEvent } from '../common/agentService.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import { ActionType, ISessionAction } from '../common/state/sessionActions.js';
import {
@@ -20,6 +21,7 @@ import {
type URI as ProtocolURI,
} from '../common/state/sessionState.js';
import { AgentEventMapper } from './agentEventMapper.js';
import { CommandAutoApprover } from './commandAutoApprover.js';
import { SessionStateManager } from './sessionStateManager.js';
/**
@@ -50,6 +52,8 @@ export class AgentSideEffects extends Disposable {
private readonly _toolCallAgents = new Map<string, string>();
/** Per-agent event mapper instances (stateful for partId tracking). */
private readonly _eventMappers = new Map<string, AgentEventMapper>();
/** Auto-approver for shell commands parsed via tree-sitter. */
private readonly _commandAutoApprover: CommandAutoApprover;
constructor(
private readonly _stateManager: SessionStateManager,
@@ -57,6 +61,7 @@ export class AgentSideEffects extends Disposable {
private readonly _logService: ILogService,
) {
super();
this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService));
// Whenever the agents observable changes, publish to root state.
this._register(autorun(reader => {
@@ -118,6 +123,61 @@ export class AgentSideEffects extends Disposable {
return approved;
}
/**
* Initializes async resources (tree-sitter WASM) used for command
* auto-approval. Await this before any session events can arrive to
* guarantee that {@link _tryAutoApproveToolReady} is fully synchronous.
*/
initialize(): Promise<void> {
return this._commandAutoApprover.initialize();
}
/**
* Synchronously attempts to auto-approve a `tool_ready` event based on
* permission kind. Returns `true` if auto-approved (event should not be
* dispatched to the state manager), or `false` to proceed normally.
*/
private _tryAutoApproveToolReady(
e: { readonly toolCallId: string; readonly session: URI; readonly permissionKind?: string; readonly permissionPath?: string; readonly toolInput?: string },
sessionKey: ProtocolURI,
agent: IAgent,
): boolean {
// Write auto-approval: only within the session's working directory,
// then apply the default glob patterns for protected files.
if (e.permissionKind === 'write' && e.permissionPath) {
const sessionState = this._stateManager.getSessionState(sessionKey);
const workDir = sessionState?.workingDirectory ?? sessionState?.summary.workingDirectory;
const workingDirectory = workDir ? URI.parse(workDir) : undefined;
if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(e.permissionPath)), workingDirectory)) {
if (this._shouldAutoApproveEdit(e.permissionPath)) {
this._logService.trace(`[AgentSideEffects] Auto-approving write to ${e.permissionPath}`);
this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`);
agent.respondToPermissionRequest(e.toolCallId, true);
return true;
}
}
return false;
}
// Shell auto-approval: parse the command via tree-sitter (synchronous
// after initialize() has been awaited) and match against default rules.
if (e.permissionKind === 'shell' && e.toolInput) {
const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput);
if (result === 'approved') {
this._logService.trace(`[AgentSideEffects] Auto-approving shell command`);
this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`);
agent.respondToPermissionRequest(e.toolCallId, true);
return true;
}
if (result === 'denied') {
this._logService.trace(`[AgentSideEffects] Shell command denied by rule`);
}
return false;
}
return false;
}
// ---- Agent registration -------------------------------------------------
/**
@@ -143,26 +203,15 @@ export class AgentSideEffects extends Disposable {
const sessionKey = e.session.toString();
const turnId = this._stateManager.getActiveTurnId(sessionKey);
if (turnId) {
// Check if this is a write permission request that can be auto-approved
// based on the built-in default patterns.
if (e.type === 'tool_ready' && e.permissionKind === 'write' && e.permissionPath) {
if (this._shouldAutoApproveEdit(e.permissionPath)) {
this._logService.trace(`[AgentSideEffects] Auto-approving write to ${e.permissionPath}`);
agent.respondToPermissionRequest(e.toolCallId, true);
// Auto-approve tool_ready events synchronously before dispatching.
// Tree-sitter is pre-warmed via initialize(), so this is fully sync.
if (e.type === 'tool_ready') {
if (this._tryAutoApproveToolReady(e, sessionKey, agent)) {
return;
}
}
const actions = agentMapper.mapProgressEventToActions(e, sessionKey, turnId);
if (actions) {
if (Array.isArray(actions)) {
for (const action of actions) {
this._stateManager.dispatchServerAction(action);
}
} else {
this._stateManager.dispatchServerAction(actions);
}
}
this._dispatchProgressActions(agentMapper, e, sessionKey, turnId);
}
// After a turn completes (idle event), try to consume the next queued message
@@ -185,6 +234,19 @@ export class AgentSideEffects extends Disposable {
// ---- Side-effect handlers --------------------------------------------------
private _dispatchProgressActions(mapper: AgentEventMapper, e: IAgentProgressEvent, sessionKey: ProtocolURI, turnId: string): void {
const actions = mapper.mapProgressEventToActions(e, sessionKey, turnId);
if (actions) {
if (Array.isArray(actions)) {
for (const action of actions) {
this._stateManager.dispatchServerAction(action);
}
} else {
this._stateManager.dispatchServerAction(actions);
}
}
}
handleAction(action: ISessionAction): void {
switch (action.type) {
case ActionType.SessionTurnStarted: {

View File

@@ -0,0 +1,418 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm';
import * as fs from 'fs';
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
import { FileAccess } from '../../../base/common/network.js';
import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
/** Pattern that detects compound commands (&&, ||, ;, |, backtick, $()) */
const compoundCommandPattern = /&&|\|\||[;|]|`|\$\(/;
/**
* Result of a command auto-approval check.
* - `approved`: all sub-commands match allow rules and none are denied
* - `denied`: at least one sub-command matches a deny rule
* - `noMatch`: no rule matched — requires user confirmation
*/
export type CommandApprovalResult = 'approved' | 'denied' | 'noMatch';
interface IAutoApproveRule {
readonly regex: RegExp;
}
const neverMatchRegex = /(?!.*)/;
const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i;
/**
* Auto-approves or denies shell commands based on default rules.
*
* Uses tree-sitter to parse compound commands (`foo && bar`) into
* sub-commands that are individually checked against allow/deny lists.
* The default rules mirror the VS Code `chat.tools.terminal.autoApprove`
* setting defaults.
*
* Tree-sitter is initialized eagerly; call {@link initialize} and await the
* result before using {@link shouldAutoApprove} to guarantee synchronous
* parsing. If tree-sitter failed to load, compound commands fall back to
* `noMatch` (user confirmation required).
*/
export class CommandAutoApprover extends Disposable {
private _allowRules: IAutoApproveRule[] | undefined;
private _denyRules: IAutoApproveRule[] | undefined;
private _parser: Parser | undefined;
private _bashLanguage: Language | undefined;
private _queryClass: typeof Query | undefined;
private readonly _initPromise: Promise<void>;
constructor(
private readonly _logService: ILogService,
) {
super();
this._initPromise = this._initTreeSitter();
}
/**
* Returns a promise that resolves once tree-sitter WASM has been loaded.
* Await this before processing any events to guarantee that
* {@link shouldAutoApprove} can parse commands synchronously.
*/
initialize(): Promise<void> {
return this._initPromise;
}
/**
* Synchronously check whether the given command line should be auto-approved.
* Uses tree-sitter (if loaded) to parse compound commands into sub-commands.
*/
shouldAutoApprove(commandLine: string): CommandApprovalResult {
const trimmed = commandLine.trimStart();
if (trimmed.length === 0) {
return 'approved';
}
this._ensureRules();
// Try to extract sub-commands via tree-sitter
const subCommands = this._extractSubCommands(trimmed);
if (subCommands && subCommands.length > 0) {
return this._matchSubCommands(subCommands);
}
// Fallback: if this looks like a compound command but tree-sitter
// failed to parse it, require user confirmation rather than risking
// auto-approving a dangerous sub-command.
if (compoundCommandPattern.test(trimmed)) {
this._logService.trace('[CommandAutoApprover] Compound command without tree-sitter, requiring confirmation');
return 'noMatch';
}
// Simple single command — match against rules
return this._matchCommandLine(trimmed);
}
private _matchSubCommands(subCommands: string[]): CommandApprovalResult {
let allApproved = true;
for (const subCommand of subCommands) {
// Deny transient env var assignments
if (transientEnvVarRegex.test(subCommand)) {
return 'denied';
}
const result = this._matchSingleCommand(subCommand);
if (result === 'denied') {
return 'denied';
}
if (result !== 'approved') {
allApproved = false;
}
}
return allApproved ? 'approved' : 'noMatch';
}
private _matchCommandLine(commandLine: string): CommandApprovalResult {
if (transientEnvVarRegex.test(commandLine)) {
return 'denied';
}
return this._matchSingleCommand(commandLine);
}
private _matchSingleCommand(command: string): CommandApprovalResult {
// Check deny rules first
for (const rule of this._denyRules!) {
if (rule.regex.test(command)) {
return 'denied';
}
}
// Then check allow rules
for (const rule of this._allowRules!) {
if (rule.regex.test(command)) {
return 'approved';
}
}
return 'noMatch';
}
// ---- Tree-sitter --------------------------------------------------------
private _extractSubCommands(commandLine: string): string[] | undefined {
if (!this._parser || !this._bashLanguage || !this._queryClass) {
return undefined;
}
try {
this._parser.setLanguage(this._bashLanguage);
const tree = this._parser.parse(commandLine);
if (!tree) {
return undefined;
}
try {
const query = new this._queryClass(this._bashLanguage, '(command) @command');
const captures: QueryCapture[] = query.captures(tree.rootNode);
const subCommands = captures.map(c => c.node.text);
query.delete();
return subCommands.length > 0 ? subCommands : undefined;
} finally {
tree.delete();
}
} catch (err) {
this._logService.warn('[CommandAutoApprover] Tree-sitter parsing failed', err);
return undefined;
}
}
private async _initTreeSitter(): Promise<void> {
try {
const TreeSitter = await import('@vscode/tree-sitter-wasm');
// Resolve WASM files from node_modules
const moduleRoot = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@vscode', 'tree-sitter-wasm', 'wasm');
const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath;
await TreeSitter.Parser.init({
locateFile() {
return wasmPath;
}
});
const parser = new TreeSitter.Parser();
this._register(toDisposable(() => parser.delete()));
// Load bash grammar
const bashWasmPath = URI.joinPath(moduleRoot, 'tree-sitter-bash.wasm').fsPath;
const bashWasm = await fs.promises.readFile(bashWasmPath);
const bashLanguage = await TreeSitter.Language.load(new Uint8Array(bashWasm.buffer, bashWasm.byteOffset, bashWasm.byteLength));
this._parser = parser;
this._bashLanguage = bashLanguage;
this._queryClass = TreeSitter.Query;
this._logService.info('[CommandAutoApprover] Tree-sitter initialized successfully');
} catch (err) {
this._logService.warn('[CommandAutoApprover] Failed to initialize tree-sitter', err);
}
}
// ---- Rules --------------------------------------------------------------
private _ensureRules(): void {
if (this._allowRules && this._denyRules) {
return;
}
const allowRules: IAutoApproveRule[] = [];
const denyRules: IAutoApproveRule[] = [];
for (const [key, value] of Object.entries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES)) {
const regex = convertAutoApproveEntryToRegex(key);
if (value === true) {
allowRules.push({ regex });
} else if (value === false) {
denyRules.push({ regex });
}
}
this._allowRules = allowRules;
this._denyRules = denyRules;
}
}
// ---- Regex conversion -------------------------------------------------------
function convertAutoApproveEntryToRegex(value: string): RegExp {
// If wrapped in `/`, treat as regex
const regexMatch = value.match(/^\/(?<pattern>.+)\/(?<flags>[dgimsuvy]*)$/);
const regexPattern = regexMatch?.groups?.pattern;
if (regexPattern) {
let flags = regexMatch.groups?.flags;
if (flags) {
flags = flags.replaceAll('g', '');
}
if (regexPattern === '.*') {
return new RegExp(regexPattern);
}
try {
const regex = new RegExp(regexPattern, flags || undefined);
if (regExpLeadsToEndlessLoop(regex)) {
return neverMatchRegex;
}
return regex;
} catch {
return neverMatchRegex;
}
}
if (value === '') {
return neverMatchRegex;
}
let sanitizedValue: string;
// Match both path separators if it looks like a path
if (value.includes('/') || value.includes('\\')) {
let pattern = value.replace(/[/\\]/g, '%%PATH_SEP%%');
pattern = escapeRegExpCharacters(pattern);
pattern = pattern.replace(/%%PATH_SEP%%*/g, '[/\\\\]');
sanitizedValue = `^(?:\\.[/\\\\])?${pattern}`;
} else {
sanitizedValue = escapeRegExpCharacters(value);
}
return new RegExp(`^${sanitizedValue}\\b`);
}
// ---- Default rules ----------------------------------------------------------
//
// These mirror the VS Code `chat.tools.terminal.autoApprove` setting defaults.
// Kept in sync manually — the actual setting will be wired up later.
const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly<Record<string, boolean>> = {
// Safe readonly commands
cd: true,
echo: true,
ls: true,
dir: true,
pwd: true,
cat: true,
head: true,
tail: true,
findstr: true,
wc: true,
tr: true,
cut: true,
cmp: true,
which: true,
basename: true,
dirname: true,
realpath: true,
readlink: true,
stat: true,
file: true,
od: true,
du: true,
df: true,
sleep: true,
nl: true,
grep: true,
// Safe git sub-commands
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*\\s-(d|D|m|M|-delete|-force)\\b/': false,
// Docker readonly sub-commands
'/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true,
'/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true,
'/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true,
// PowerShell
'Get-ChildItem': true,
'Get-Content': true,
'Get-Date': true,
'Get-Random': true,
'Get-Location': true,
'Set-Location': true,
'Write-Host': true,
'Write-Output': true,
'Out-String': true,
'Split-Path': true,
'Join-Path': true,
'Start-Sleep': true,
'Where-Object': true,
'/^Select-[a-z0-9]/i': true,
'/^Measure-[a-z0-9]/i': true,
'/^Compare-[a-z0-9]/i': true,
'/^Format-[a-z0-9]/i': true,
'/^Sort-[a-z0-9]/i': true,
// Package manager read-only commands
'/^npm\\s+(ls|list|outdated|view|info|show|explain|why|root|prefix|bin|search|doctor|fund|repo|bugs|docs|home|help(-search)?)\\b/': true,
'/^npm\\s+config\\s+(list|get)\\b/': true,
'/^npm\\s+pkg\\s+get\\b/': true,
'/^npm\\s+audit$/': true,
'/^npm\\s+cache\\s+verify\\b/': true,
'/^yarn\\s+(list|outdated|info|why|bin|help|versions)\\b/': true,
'/^yarn\\s+licenses\\b/': true,
'/^yarn\\s+audit\\b(?!.*\\bfix\\b)/': true,
'/^yarn\\s+config\\s+(list|get)\\b/': true,
'/^yarn\\s+cache\\s+dir\\b/': true,
'/^pnpm\\s+(ls|list|outdated|why|root|bin|doctor)\\b/': true,
'/^pnpm\\s+licenses\\b/': true,
'/^pnpm\\s+audit\\b(?!.*\\bfix\\b)/': true,
'/^pnpm\\s+config\\s+(list|get)\\b/': true,
// Safe lockfile-only installs
'npm ci': true,
'/^yarn\\s+install\\s+--frozen-lockfile\\b/': true,
'/^pnpm\\s+install\\s+--frozen-lockfile\\b/': true,
// Safe commands with dangerous arg blocking
column: true,
'/^column\\b.*\\s-c\\s+[0-9]{4,}/': false,
date: true,
'/^date\\b.*\\s(-s|--set)\\b/': false,
find: true,
'/^find\\b.*\\s-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/': false,
rg: true,
'/^rg\\b.*\\s(--pre|--hostname-bin)\\b/': false,
sed: true,
'/^sed\\b.*\\s(-[a-zA-Z]*(e|f)[a-zA-Z]*|--expression|--file)\\b/': false,
'/^sed\\b.*s\\/.*\\/.*\\/[ew]/': false,
'/^sed\\b.*;W/': false,
sort: true,
'/^sort\\b.*\\s-(o|S)\\b/': false,
tree: true,
'/^tree\\b.*\\s-o\\b/': false,
'/^xxd$/': true,
'/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true,
// Dangerous commands
rm: false,
rmdir: false,
del: false,
'Remove-Item': false,
ri: false,
rd: false,
erase: false,
dd: false,
kill: false,
ps: false,
top: false,
'Stop-Process': false,
spps: false,
taskkill: false,
'taskkill.exe': false,
curl: false,
wget: false,
'Invoke-RestMethod': false,
'Invoke-WebRequest': false,
irm: false,
iwr: false,
chmod: false,
chown: false,
'Set-ItemProperty': false,
sp: false,
'Set-Acl': false,
jq: false,
xargs: false,
eval: false,
'Invoke-Expression': false,
iex: false,
};

View File

@@ -40,7 +40,7 @@ suite('AgentSideEffects', () => {
const sessionUri = AgentSession.uri('mock', 'session-1');
function setupSession(): void {
function setupSession(workingDirectory?: string): void {
stateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
@@ -48,6 +48,7 @@ suite('AgentSideEffects', () => {
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
workingDirectory,
});
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });
}
@@ -615,7 +616,7 @@ suite('AgentSideEffects', () => {
suite('edit auto-approve', () => {
test('auto-approves writes to regular source files', async () => {
setupSession();
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
@@ -644,7 +645,7 @@ suite('AgentSideEffects', () => {
});
test('blocks writes to .env files', () => {
setupSession();
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
@@ -679,7 +680,7 @@ suite('AgentSideEffects', () => {
});
test('blocks writes to package.json', () => {
setupSession();
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
@@ -706,7 +707,7 @@ suite('AgentSideEffects', () => {
});
test('blocks writes to .lock files', () => {
setupSession();
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
@@ -733,7 +734,7 @@ suite('AgentSideEffects', () => {
});
test('blocks writes to .git directory', () => {
setupSession();
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));

View File

@@ -0,0 +1,141 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { CommandAutoApprover } from '../../node/commandAutoApprover.js';
suite('CommandAutoApprover', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
let approver: CommandAutoApprover;
setup(() => {
approver = disposables.add(new CommandAutoApprover(new NullLogService()));
});
suite('shouldAutoApprove', () => {
test('approves empty command', () => {
assert.strictEqual(approver.shouldAutoApprove(''), 'approved');
assert.strictEqual(approver.shouldAutoApprove(' '), 'approved');
});
// Safe readonly commands
test('approves allowed readonly commands', () => {
assert.strictEqual(approver.shouldAutoApprove('ls'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('ls -la'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('cat file.txt'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('head -n 10 file.txt'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('tail -f log.txt'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('pwd'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('echo hello'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('grep -r pattern .'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('wc -l file.txt'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('which node'), 'approved');
});
// Dangerous commands
test('denies denied commands', () => {
assert.strictEqual(approver.shouldAutoApprove('rm file.txt'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('rm -rf /'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('rmdir folder'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('kill -9 1234'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('curl http://evil.com'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('wget http://evil.com'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('chmod 777 file'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('chown root file'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('eval "bad stuff"'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('xargs rm'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('dd if=/dev/zero of=/dev/sda'), 'denied');
});
// Safe git sub-commands
test('approves allowed git sub-commands', () => {
assert.strictEqual(approver.shouldAutoApprove('git status'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('git log --oneline'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('git diff HEAD'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('git show HEAD'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('git ls-files'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('git branch'), 'approved');
});
// Unsafe git sub-commands
test('denies denied git operations', () => {
assert.strictEqual(approver.shouldAutoApprove('git branch -D main'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('git branch --delete main'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('git log --output=/tmp/out'), 'denied');
});
// Safe commands with dangerous arg blocking
test('handles find with blocked args', () => {
assert.strictEqual(approver.shouldAutoApprove('find . -name "*.ts"'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('find . -delete'), 'denied');
// find -exec with ; is treated as a compound command, requiring confirmation
assert.strictEqual(approver.shouldAutoApprove('find . -exec rm {} ;'), 'noMatch');
});
test('handles sed with blocked args', () => {
assert.strictEqual(approver.shouldAutoApprove('sed "s/foo/bar/g" file.txt'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('sed -e "s/foo/bar/"'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('sed --expression "s/foo/bar/"'), 'denied');
});
// npm/package managers
test('approves allowed npm commands', () => {
assert.strictEqual(approver.shouldAutoApprove('npm ci'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('npm ls'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('npm audit'), 'approved');
});
// Unknown commands get noMatch
test('returns noMatch for unknown commands', () => {
assert.strictEqual(approver.shouldAutoApprove('my-custom-script'), 'noMatch');
assert.strictEqual(approver.shouldAutoApprove('python script.py'), 'noMatch');
assert.strictEqual(approver.shouldAutoApprove('node index.js'), 'noMatch');
});
// Transient env vars
test('denies transient environment variable assignments', () => {
assert.strictEqual(approver.shouldAutoApprove('FOO=bar some-command'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('PATH=/evil:$PATH ls'), 'denied');
});
// PowerShell
test('approves allowed PowerShell commands', () => {
assert.strictEqual(approver.shouldAutoApprove('Get-ChildItem'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('Get-Content file.txt'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('Write-Host "hello"'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('Select-Object Name'), 'approved');
});
test('PowerShell case-insensitive rules work', () => {
// Rules with /i flag (like Select-*, Measure-*, etc.) are case-insensitive
assert.strictEqual(approver.shouldAutoApprove('select-object Name'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('SELECT-OBJECT Name'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('Measure-Command'), 'approved');
assert.strictEqual(approver.shouldAutoApprove('measure-command'), 'approved');
});
test('denies denied PowerShell commands', () => {
assert.strictEqual(approver.shouldAutoApprove('Remove-Item file.txt'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('Invoke-Expression "bad"'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('Invoke-WebRequest http://evil.com'), 'denied');
assert.strictEqual(approver.shouldAutoApprove('Stop-Process -Id 1234'), 'denied');
});
// Compound commands containing denied sub-commands should never be auto-approved,
// regardless of whether tree-sitter is available (with tree-sitter they are
// 'denied', without they are 'noMatch' — both are safe).
test('compound commands with denied sub-commands are not auto-approved', () => {
assert.notStrictEqual(approver.shouldAutoApprove('echo ok && rm -rf /'), 'approved');
assert.notStrictEqual(approver.shouldAutoApprove('ls || curl evil.com'), 'approved');
assert.notStrictEqual(approver.shouldAutoApprove('cat file; rm file'), 'approved');
assert.notStrictEqual(approver.shouldAutoApprove('echo $(whoami)'), 'approved');
});
});
});

View File

@@ -293,6 +293,42 @@ export class ScriptedMockAgent implements IAgent {
break;
}
case 'run-safe-command': {
// Fire tool_start + tool_ready with shell permission for an allowed command (should be auto-approved)
(async () => {
await timeout(10);
this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-shell-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' });
await timeout(5);
this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-shell-1', invocationMessage: 'ls -la', permissionKind: 'shell', toolInput: 'ls -la' });
// Auto-approved shell commands resolve immediately
await timeout(10);
this._fireSequence(session, [
{ type: 'tool_complete', session, toolCallId: 'tc-shell-1', result: { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } },
{ type: 'idle', session },
]);
})();
break;
}
case 'run-dangerous-command': {
// Fire tool_start + tool_ready with shell permission for a denied command (should require confirmation)
(async () => {
await timeout(10);
this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-shell-deny-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' });
await timeout(5);
this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-shell-deny-1', invocationMessage: 'rm -rf /', permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' });
})();
this._pendingPermissions.set('tc-shell-deny-1', (approved) => {
if (approved) {
this._fireSequence(session, [
{ type: 'tool_complete', session, toolCallId: 'tc-shell-deny-1', result: { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true } },
{ type: 'idle', session },
]);
}
});
break;
}
case 'with-usage':
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Usage response.' },

View File

@@ -241,10 +241,10 @@ function getActionEnvelope(n: IAhpNotification): IActionEnvelope {
}
/** Perform handshake, create a session, subscribe, and return its URI. */
async function createAndSubscribeSession(c: TestProtocolClient, clientId: string): Promise<string> {
async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise<string> {
await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId });
await c.call('createSession', { session: nextSessionUri(), provider: 'mock' });
await c.call('createSession', { session: nextSessionUri(), provider: 'mock', workingDirectory });
const notif = await c.waitForNotification(n =>
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
@@ -926,7 +926,7 @@ suite('Protocol WebSocket E2E', function () {
test('auto-approves write to regular file (no pending confirmation)', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove');
const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', 'file:///workspace');
client.clearReceived();
// Start a turn that triggers a write permission request for a regular .ts file
@@ -952,7 +952,7 @@ suite('Protocol WebSocket E2E', function () {
test('blocks write to .env file (requires manual confirmation)', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny');
const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', 'file:///workspace');
client.clearReceived();
// Start a turn that tries to write .env (blocked by default patterns)
@@ -977,4 +977,61 @@ suite('Protocol WebSocket E2E', function () {
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
// ---- Shell auto-approve -------------------------------------------------
test('auto-approves allowed shell command (no pending confirmation)', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-shell-approve');
client.clearReceived();
// Start a turn that triggers a shell permission request for "ls -la" (allowed command)
dispatchTurnStarted(client, sessionUri, 'turn-shell-approve', 'run-safe-command', 1);
// The shell command should be auto-approved — we should see tool_start, tool_complete, and turn_complete
// but NOT a pending-confirmation toolCallReady.
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete'));
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
// Verify no pending-confirmation toolCallReady was received
const pendingConfirmNotifs = client.receivedNotifications(n => {
if (!isActionNotification(n, 'session/toolCallReady')) {
return false;
}
const action = getActionEnvelope(n).action as { confirmed?: string };
return !action.confirmed;
});
assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for allowed shell command');
});
test('blocks denied shell command (requires manual confirmation)', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-shell-deny');
client.clearReceived();
// Start a turn that triggers a shell permission request for "rm -rf /" (denied command)
dispatchTurnStarted(client, sessionUri, 'turn-shell-deny', 'run-dangerous-command', 1);
// The denied command should NOT be auto-approved — we should see toolCallReady (pending confirmation)
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));
// Confirm it manually to let the turn complete
client.notify('dispatchAction', {
clientSeq: 2,
action: {
type: 'session/toolCallConfirmed',
session: sessionUri,
turnId: 'turn-shell-deny',
toolCallId: 'tc-shell-deny-1',
approved: true,
confirmed: 'user-action',
},
});
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
});