From 689b3352204318b9e99983f949d5bcd1d6fcc0ea Mon Sep 17 00:00:00 2001 From: David Kaya Date: Fri, 27 Mar 2026 21:36:27 +0100 Subject: [PATCH] feat: category-based approval for runtime tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/main/AryxAppService.ts | 13 ++--- src/main/sidecar/sidecarProcess.ts | 3 +- .../components/chat/ApprovalBanner.tsx | 9 ++-- src/shared/contracts/sidecar.ts | 1 + src/shared/domain/approval.ts | 22 ++++++++ src/shared/domain/tooling.ts | 45 ++++------------ tests/shared/approval.test.ts | 21 ++++++++ tests/shared/tooling.test.ts | 51 +++++++++---------- 8 files changed, 93 insertions(+), 72 deletions(-) diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index d33d8e9..4822f0c 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -42,6 +42,7 @@ import { normalizeSessionApprovalSettings, pruneApprovalPolicyTools, pruneSessionApprovalSettings, + resolveApprovalToolKey, resolvePendingApproval, type ApprovalDecision, type PendingApprovalMessageRecord, @@ -124,7 +125,7 @@ type AppServiceEvents = { type PendingApprovalHandle = { sessionId: string; requestId: string; - resolve: (decision: ApprovalDecision) => void | Promise; + resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise; }; type PendingUserInputHandle = { @@ -635,8 +636,8 @@ export class AryxAppService extends EventEmitter { await this.applyAgentActivity(workspace, session.id, requestId, event); }, async (event) => { - await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision) => - this.sidecar.resolveApproval(event.approvalId, decision)); + await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) => + this.sidecar.resolveApproval(event.approvalId, decision, alwaysApprove)); }, async (event) => { await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) => @@ -729,7 +730,7 @@ export class AryxAppService extends EventEmitter { this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId)); session.updatedAt = resolvedAt; - const approvalKey = approval.toolName ?? approval.permissionKind; + const approvalKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind); if (decision === 'approved' && alwaysApprove && approvalKey) { const existing = session.approvalSettings?.autoApprovedToolNames ?? []; if (!existing.includes(approvalKey)) { @@ -748,7 +749,7 @@ export class AryxAppService extends EventEmitter { this.pendingApprovalHandles.delete(approvalId); try { - await Promise.resolve(handle.resolve(decision)); + await Promise.resolve(handle.resolve(decision, alwaysApprove)); } catch (error) { const failedAt = nowIso(); this.rejectPendingApprovals( @@ -1396,7 +1397,7 @@ export class AryxAppService extends EventEmitter { sessionId: string, requestId: string, approval: ApprovalRequestedEvent | PendingApprovalRecord, - resolve: (decision: ApprovalDecision) => void | Promise, + resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise, ): Promise { const session = this.requireSession(workspace, sessionId); const pendingApproval = diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 9257c9a..be795b7 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -120,12 +120,13 @@ export class SidecarClient { }); } - async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise { + async resolveApproval(approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean): Promise { return this.dispatch({ type: 'resolve-approval', requestId: `approval-${Date.now()}`, approvalId, decision, + alwaysApprove: alwaysApprove ?? false, }); } diff --git a/src/renderer/components/chat/ApprovalBanner.tsx b/src/renderer/components/chat/ApprovalBanner.tsx index 1fd9afe..8d16b14 100644 --- a/src/renderer/components/chat/ApprovalBanner.tsx +++ b/src/renderer/components/chat/ApprovalBanner.tsx @@ -3,7 +3,9 @@ import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldBan, ShieldCheck, import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView'; +import { resolveApprovalToolKey } from '@shared/domain/approval'; import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval'; +import { resolveToolLabel } from '@shared/domain/tooling'; /* ── ApprovalBanner ────────────────────────────────────────── */ @@ -23,8 +25,9 @@ export function ApprovalBanner({ const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval'; const hasMessages = approval.messages && approval.messages.length > 0; const showPosition = position !== undefined && total !== undefined && total > 1; - const approvalToolKey = approval.toolName ?? approval.permissionKind; + const approvalToolKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind); const canAlwaysApprove = approval.kind === 'tool-call' && !!approvalToolKey; + const approvalToolLabel = approvalToolKey ? resolveToolLabel(approvalToolKey) : undefined; return (
@@ -91,11 +94,11 @@ export function ApprovalBanner({ {canAlwaysApprove && (