feat: category-based approval for runtime tools

Replace 20+ individual runtime tool toggles with 5 permission categories
(read, write, shell, web_fetch, store_memory) matching how Copilot CLI,
Cline, and other coding agents handle approvals.

- Add resolveApprovalToolKey() to map permission kinds to category IDs
- Simplify fallbackRuntimeApprovalTools to 5 categories
- Thread alwaysApprove through PendingApprovalHandle → sidecar resolve
- Update ApprovalBanner to use category labels for Always Approve button
- Update ResolveApprovalCommand contract with alwaysApprove field
- Update tests for category-based model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 21:36:27 +01:00
co-authored by Copilot
parent f04e3b9dcc
commit 689b335220
8 changed files with 93 additions and 72 deletions
+21
View File
@@ -9,6 +9,7 @@ import {
normalizePendingApprovalState,
normalizeSessionApprovalSettings,
pruneSessionApprovalSettings,
resolveApprovalToolKey,
dequeuePendingApprovalState,
enqueuePendingApprovalState,
listPendingApprovals,
@@ -240,4 +241,24 @@ describe('approval helpers', () => {
expect(approvalPolicyRequiresToolCallApproval(effective, 'agent-1', 'shell')).toBe(false);
expect(approvalPolicyRequiresToolCallApproval(effective, 'agent-1', 'write')).toBe(true);
});
test('resolveApprovalToolKey returns permission category for runtime tools', () => {
expect(resolveApprovalToolKey('view', 'read')).toBe('read');
expect(resolveApprovalToolKey('edit', 'write')).toBe('write');
expect(resolveApprovalToolKey('bash', 'shell')).toBe('shell');
expect(resolveApprovalToolKey('web_fetch', 'url')).toBe('web_fetch');
expect(resolveApprovalToolKey('store_memory', 'memory')).toBe('store_memory');
});
test('resolveApprovalToolKey returns tool name for non-runtime tools', () => {
expect(resolveApprovalToolKey('git.status', 'mcp')).toBe('git.status');
expect(resolveApprovalToolKey('lsp_ts_hover', 'custom-tool')).toBe('lsp_ts_hover');
expect(resolveApprovalToolKey('web_fetch', 'hook')).toBe('web_fetch');
expect(resolveApprovalToolKey('some_tool', undefined)).toBe('some_tool');
});
test('resolveApprovalToolKey returns undefined when both are absent', () => {
expect(resolveApprovalToolKey(undefined, undefined)).toBeUndefined();
expect(resolveApprovalToolKey(undefined, 'mcp')).toBeUndefined();
});
});
+24 -27
View File
@@ -228,7 +228,7 @@ describe('tooling settings helpers', () => {
});
});
test('prefers dynamically reported runtime tools over the fallback builtin catalog', () => {
test('always uses category-level builtins regardless of dynamically reported runtime tools', () => {
const tools = listApprovalToolDefinitions(
{ mcpServers: [], lspProfiles: [] },
[
@@ -240,15 +240,15 @@ describe('tooling settings helpers', () => {
],
);
expect(tools).toContainEqual({
id: 'fetch',
label: 'fetch',
description: 'Dynamic runtime tool from Copilot CLI.',
kind: 'builtin',
providerIds: ['builtin:fetch'],
providerNames: ['Built-in'],
});
expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(false);
// Category-level builtins must always be present
expect(tools.some((tool) => tool.id === 'read')).toBe(true);
expect(tools.some((tool) => tool.id === 'write')).toBe(true);
expect(tools.some((tool) => tool.id === 'shell')).toBe(true);
expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(true);
expect(tools.some((tool) => tool.id === 'store_memory')).toBe(true);
// Dynamic individual tools do NOT appear in the approval list
expect(tools.some((tool) => tool.id === 'fetch')).toBe(false);
});
test('resolves human-readable labels from the tool metadata registry', () => {
@@ -268,36 +268,33 @@ describe('tooling settings helpers', () => {
expect(resolveToolLabel('')).toBe('');
});
test('fallback catalog includes all standard non-internal tools with friendly labels', () => {
test('fallback catalog uses 5 permission-category entries for built-in tools', () => {
const tools = listApprovalToolDefinitions({ mcpServers: [], lspProfiles: [] });
const builtinTools = tools.filter((t) => t.kind === 'builtin');
expect(builtinTools.length).toBeGreaterThanOrEqual(23);
expect(builtinTools.some((t) => t.id === 'bash')).toBe(true);
expect(builtinTools.some((t) => t.id === 'web_fetch')).toBe(true);
expect(builtinTools.some((t) => t.id === 'task')).toBe(true);
expect(builtinTools.some((t) => t.id === 'store_memory')).toBe(true);
expect(builtinTools.length).toBe(5);
// Permission-kind approval categories should be included
expect(builtinTools.some((t) => t.id === 'shell')).toBe(true);
// Permission-kind approval categories
expect(builtinTools.some((t) => t.id === 'read')).toBe(true);
expect(builtinTools.some((t) => t.id === 'write')).toBe(true);
expect(builtinTools.some((t) => t.id === 'shell')).toBe(true);
expect(builtinTools.some((t) => t.id === 'web_fetch')).toBe(true);
expect(builtinTools.some((t) => t.id === 'store_memory')).toBe(true);
// Labels should be human-readable, not raw IDs
const bashTool = builtinTools.find((t) => t.id === 'bash');
expect(bashTool?.label).toBe('Execute shell commands');
const shellTool = builtinTools.find((t) => t.id === 'shell');
expect(shellTool?.label).toBe('Shell commands');
const readTool = builtinTools.find((t) => t.id === 'read');
expect(readTool?.label).toBe('Read files');
const writeTool = builtinTools.find((t) => t.id === 'write');
expect(writeTool?.label).toBe('Write files');
const shellTool = builtinTools.find((t) => t.id === 'shell');
expect(shellTool?.label).toBe('Shell commands');
// Internal tools should not appear in the fallback
expect(builtinTools.some((t) => t.id === 'ask_user')).toBe(false);
expect(builtinTools.some((t) => t.id === 'report_intent')).toBe(false);
expect(builtinTools.some((t) => t.id === 'task_complete')).toBe(false);
expect(builtinTools.some((t) => t.id === 'exit_plan_mode')).toBe(false);
// Individual tools should NOT appear in the approval list
expect(builtinTools.some((t) => t.id === 'bash')).toBe(false);
expect(builtinTools.some((t) => t.id === 'view')).toBe(false);
expect(builtinTools.some((t) => t.id === 'edit')).toBe(false);
expect(builtinTools.some((t) => t.id === 'grep')).toBe(false);
expect(builtinTools.some((t) => t.id === 'task')).toBe(false);
});
test('resolves workspace and project tooling with accepted discovered MCP servers', () => {