Files

529 lines
18 KiB
TypeScript
Raw Permalink Normal View History

/**
* approvalGate Promise-based approval system for tool execution.
*
* Catty write tools are gated by `streamText({ toolApproval })` (see cattyToolApproval.ts).
* MCP/external agents use main-process approval via `setupMcpApprovalBridge()`.
* `requestApproval()` is the shared renderer Promise used by both paths.
* a Promise that resolves when the user approves/rejects from the UI, or after
* a timeout (default 5 minutes) to prevent indefinite hangs.
*
* Also supports MCP/SDK-agent tool calls from the Electron main process:
* the main process sends an IPC approval request, and we route it
* through the same listener/UI system. MCP approvals are stored in
* the same pendingApprovals map so they survive ChatMessageList
* unmount/remount cycles via replayPendingApprovals().
*
* Approvals are scoped by optional chatSessionId to prevent cross-session
* interference when stopping or cancelling sessions.
*/
import {
CATTY_APPROVAL_TIMEOUT_MS,
resolveCattyApprovalDeadlines,
} from './approvalConstants';
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
import { STORAGE_KEY_AI_PERMISSION_GRANTS } from '../../config/storageKeys';
import { globalTraceStore } from '../harness/traceStore';
import {
getActivePermissionGrants,
matchPermissionGrant,
resolveCapabilityId,
sanitizePermissionGrants,
setActivePermissionGrants,
type PermissionGrantRule,
} from '../harness/permissionGrants';
export interface ApprovalRequest {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
/** Optional chat session scope — used to clear only relevant approvals on stop */
chatSessionId?: string;
capabilityId?: string;
source?: 'catty' | 'mcp' | 'codex-app-server';
approvalType?: 'command' | 'file-change' | 'permissions';
itemId?: string;
allowSession?: boolean;
}
export interface ResolveApprovalOptions {
approved: boolean;
persistGrant?: PermissionGrantRule;
persistGrants?: PermissionGrantRule[];
scope?: 'once' | 'session';
cancelled?: boolean;
}
export type ApprovalResolution = {
approved: boolean;
scope: 'once' | 'session';
cancelled: boolean;
};
export type GrantPersister = (rule: PermissionGrantRule) => void;
let grantPersister: GrantPersister | null = null;
const grantPersisterStack: GrantPersister[] = [];
function refreshPermissionGrantsFromStorage(): void {
if (typeof window === 'undefined') return;
setActivePermissionGrants(
sanitizePermissionGrants(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_PERMISSION_GRANTS)),
);
}
export function setGrantPersister(persister: GrantPersister | null): void {
grantPersisterStack.length = 0;
if (persister) {
grantPersisterStack.push(persister);
}
grantPersister = persister;
}
/** Register a grant persister; supports multiple mounted AI panels via a stack. */
export function registerGrantPersister(persister: GrantPersister): () => void {
grantPersisterStack.push(persister);
grantPersister = persister;
return () => {
const idx = grantPersisterStack.lastIndexOf(persister);
if (idx >= 0) {
grantPersisterStack.splice(idx, 1);
}
grantPersister = grantPersisterStack[grantPersisterStack.length - 1] ?? null;
};
}
// Pending approval entries keyed by toolCallId.
// SDK approvals have a real `resolve` callback; MCP approvals use a no-op
// (the real resolution goes via IPC in resolveApproval).
const pendingApprovals = new Map<string, {
resolve: (resolution: ApprovalResolution) => void;
request: ApprovalRequest;
/** Clears the auto-deny timer without resolving the approval. */
cancelTimeout?: () => void;
}>();
// Subscribers for approval request events (UI listens here)
type ApprovalRequestListener = (request: ApprovalRequest) => void;
const listeners = new Set<ApprovalRequestListener>();
// Subscribers for approval cleared/removed events (UI listens to clean up cards)
type ApprovalClearedListener = (toolCallIds: string[]) => void;
const clearedListeners = new Set<ApprovalClearedListener>();
let approvalEventCounter = 0;
function nextApprovalEventId(prefix: string): string {
approvalEventCounter += 1;
return `${prefix}-${Date.now()}-${approvalEventCounter}`;
}
function emitApprovalEvent(
type: 'approval_requested' | 'approval_resolved',
request: ApprovalRequest,
extra?: { outcome?: 'approved' | 'denied' | 'timeout'; persistedGrantId?: string },
): void {
const sessionId = request.chatSessionId ?? 'global';
const base = {
sessionId,
chatSessionId: request.chatSessionId,
backend: request.source === 'codex-app-server' ? 'external-sdk' as const : 'catty' as const,
timestamp: Date.now(),
toolCallId: request.toolCallId,
toolName: request.toolName,
};
if (type === 'approval_requested') {
globalTraceStore.append({
...base,
id: nextApprovalEventId('approval-requested'),
type: 'approval_requested',
args: request.args,
});
return;
}
globalTraceStore.append({
...base,
id: nextApprovalEventId('approval-resolved'),
type: 'approval_resolved',
outcome: extra?.outcome ?? 'denied',
persistedGrantId: extra?.persistedGrantId,
});
}
function isGrantedByRules(request: ApprovalRequest): boolean {
refreshPermissionGrantsFromStorage();
const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName);
return matchPermissionGrant(getActivePermissionGrants(), {
capabilityId,
chatSessionId: request.chatSessionId,
sessionId: typeof request.args.sessionId === 'string' ? request.args.sessionId : undefined,
args: request.args,
}) !== null;
}
/**
* Called from a tool's `execute` function when it needs user approval.
* Returns a Promise<boolean> that resolves to `true` (approved) or `false` (denied).
* The UI is notified via the listener system to render approval buttons.
*
* Idle auto-deny uses `timeoutMs` (default 5 minutes). Review activity re-arms
* a fresh idle window from *now*, but never past a hard deadline from creation
* (default 3× idle, capped at 30 minutes) so late review is not cut off at the
* original idle mark while still staying bounded.
*/
export function requestApproval(
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
chatSessionId?: string,
timeoutMs: number = CATTY_APPROVAL_TIMEOUT_MS,
capabilityId?: string,
): Promise<boolean> {
const request: ApprovalRequest = {
toolCallId,
toolName,
args,
chatSessionId,
capabilityId: capabilityId ?? resolveCapabilityId(toolName),
};
if (isGrantedByRules(request)) {
return Promise.resolve(true);
}
const existing = pendingApprovals.get(toolCallId);
if (existing) {
return new Promise<boolean>((resolve) => {
const previousResolve = existing.resolve;
existing.resolve = (resolution) => {
previousResolve(resolution);
resolve(resolution.approved);
};
});
}
emitApprovalEvent('approval_requested', request);
return new Promise<boolean>((resolve) => {
let timerId: ReturnType<typeof setTimeout> | null = null;
const { idleMs, hardDeadlineMs } = resolveCattyApprovalDeadlines(timeoutMs);
// Hard ceiling from creation — review re-arms idle from now for idleMs
// but never past this bound.
const hardDeadlineAt = Date.now() + hardDeadlineMs;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
const wrappedResolve = (resolution: ApprovalResolution) => {
clearTimer();
resolve(resolution.approved);
};
const denyTimedOut = () => {
const entry = pendingApprovals.get(toolCallId);
if (!entry) return;
pendingApprovals.delete(toolCallId);
wrappedResolve({ approved: false, scope: 'once', cancelled: false });
emitApprovalEvent('approval_resolved', request, { outcome: 'timeout' });
for (const cl of clearedListeners) {
try { cl([toolCallId]); } catch { /* ignore */ }
}
};
const armTimer = (ms: number) => {
clearTimer();
if (ms <= 0) {
// Hard deadline already elapsed — reject synchronously so a late
// approve cannot race a deferred setTimeout(0) deny.
denyTimedOut();
return;
}
timerId = setTimeout(denyTimedOut, ms);
};
// Initial arm is the idle window. Review re-arms idle (capped by hard deadline).
armTimer(idleMs);
pendingApprovals.set(toolCallId, {
resolve: wrappedResolve,
request,
cancelTimeout: () => {
const remainingHard = hardDeadlineAt - Date.now();
if (remainingHard <= 0) {
armTimer(0);
return;
}
// Re-arm a full idle window from now, never past the hard deadline.
armTimer(Math.min(idleMs, remainingHard));
},
});
// Notify all UI listeners
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
});
}
/**
* Cancel / reset the idle auto-deny timer after the user starts reviewing or
* interacting with the card. Catty local approvals re-arm idle from *now* for
* a fresh idleMs window, never past the hard creation deadline.
* Timeout never auto-approves.
*/
export function cancelApprovalTimeout(toolCallId: string): void {
const entry = pendingApprovals.get(toolCallId);
// Keep cancelTimeout registered so further review activity can re-arm idle.
entry?.cancelTimeout?.();
// MCP / Codex App Server approvals are timed in the main process.
// MCP cancel drops idle but keeps the absolute creation deadline.
if (toolCallId.startsWith('mcp_approval_')) {
const bridge = (window as unknown as {
netcatty?: { cancelMcpApprovalTimeout?: (id: string) => Promise<unknown> };
}).netcatty;
void bridge?.cancelMcpApprovalTimeout?.(toolCallId);
} else if (toolCallId.startsWith('codex_interaction_')) {
const bridge = (window as unknown as {
netcatty?: { cancelCodexAppServerInteractionTimeout?: (id: string) => Promise<unknown> };
}).netcatty;
void bridge?.cancelCodexAppServerInteractionTimeout?.(toolCallId);
}
}
/**
* Called from the UI when the user approves or rejects a tool execution.
* Handles both SDK tool calls (local Promise) and MCP tool calls (IPC to main process).
*/
export function resolveApproval(
toolCallId: string,
decision: boolean | ResolveApprovalOptions,
): void {
const approved = typeof decision === 'boolean' ? decision : decision.approved;
const persistGrant = typeof decision === 'boolean' ? undefined : decision.persistGrant;
const persistGrants = typeof decision === 'boolean'
? undefined
: (decision.persistGrants ?? (persistGrant ? [persistGrant] : undefined));
const resolution: ApprovalResolution = {
approved,
scope: typeof decision === 'boolean' ? 'once' : (decision.scope ?? 'once'),
cancelled: typeof decision === 'boolean' ? false : decision.cancelled === true,
};
const entry = pendingApprovals.get(toolCallId);
const request = entry?.request;
if (!entry) {
// Stale UI click after the map entry was already drained — do not fan out
// a second MCP IPC response for a missing approval.
return;
}
pendingApprovals.delete(toolCallId);
entry.resolve(resolution);
let persistedGrantId: string | undefined;
if (approved && request?.source !== 'codex-app-server' && persistGrants?.length) {
for (const grant of persistGrants) {
grantPersister?.(grant);
persistedGrantId = grant.id;
}
}
if (request) {
emitApprovalEvent('approval_resolved', request, {
outcome: approved ? 'approved' : 'denied',
persistedGrantId,
});
}
// MCP tool call: also forward response to main process via IPC
if (toolCallId.startsWith('mcp_approval_')) {
const bridge = (window as unknown as { netcatty?: { respondMcpApproval?: (id: string, approved: boolean) => Promise<unknown> } }).netcatty;
bridge?.respondMcpApproval?.(toolCallId, approved);
}
}
/**
* Subscribe to approval request events. Returns an unsubscribe function.
*/
export function onApprovalRequest(listener: ApprovalRequestListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Subscribe to approval cleared/removed events. Returns an unsubscribe function.
* Fired when approvals are cleared (e.g. on session stop) or timed out,
* so the UI can remove stale approval cards.
*/
export function onApprovalCleared(listener: ApprovalClearedListener): () => void {
clearedListeners.add(listener);
return () => { clearedListeners.delete(listener); };
}
/**
* Replay all currently pending approval requests to a listener.
* Useful when ChatMessageList remounts after being unmounted without this,
* approvals that fired while unmounted would be silently missed and the
* corresponding execute Promises would hang indefinitely.
*
* This covers both SDK and MCP approvals since both are stored in the same map.
*/
export function replayPendingApprovals(listener: ApprovalRequestListener): void {
for (const [, entry] of pendingApprovals) {
try { listener(entry.request); } catch { /* ignore */ }
}
}
export function registerExternalApproval(
request: ApprovalRequest,
onResolve: (resolution: ApprovalResolution) => void,
): void {
if (pendingApprovals.has(request.toolCallId)) return;
emitApprovalEvent('approval_requested', request);
pendingApprovals.set(request.toolCallId, { request, resolve: onResolve });
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
}
export function clearPendingApprovalIds(toolCallIds: string[]): void {
const clearedIds: string[] = [];
for (const toolCallId of toolCallIds) {
if (pendingApprovals.delete(toolCallId)) clearedIds.push(toolCallId);
}
if (clearedIds.length > 0) {
for (const listener of clearedListeners) {
try { listener(clearedIds); } catch { /* ignore listener errors */ }
}
}
}
/**
* Check if a specific toolCallId has a pending approval.
*/
export function hasPendingApproval(toolCallId: string): boolean {
return pendingApprovals.has(toolCallId);
}
/**
* Clear pending approvals, optionally scoped to a specific chatSessionId.
* Resolves matching entries with `false` (denied) so execute functions don't hang.
* Also notifies cleared-listeners so the UI can remove stale approval cards.
*
* When chatSessionId is provided, only approvals belonging to that session
* are cleared preventing cross-session interference in concurrent chats.
* When omitted, all pending approvals are cleared (backward-compatible).
*/
export function clearAllPendingApprovals(chatSessionId?: string): void {
const clearedIds: string[] = [];
if (!chatSessionId) {
// Clear everything (legacy / global stop)
for (const [id, entry] of pendingApprovals) {
entry.resolve({ approved: false, scope: 'once', cancelled: true });
clearedIds.push(id);
}
pendingApprovals.clear();
} else {
// Scoped clear: only remove approvals for this chatSessionId
for (const [id, entry] of pendingApprovals) {
if (entry.request.chatSessionId === chatSessionId) {
pendingApprovals.delete(id);
entry.resolve({ approved: false, scope: 'once', cancelled: true });
clearedIds.push(id);
}
}
}
// Notify UI listeners to remove the cards
if (clearedIds.length > 0) {
for (const cl of clearedListeners) {
try { cl(clearedIds); } catch { /* ignore */ }
}
}
}
/**
* Set up a bridge to receive MCP/SDK-agent approval requests from the Electron main process.
* Subscribes to IPC events and stores them in the same pendingApprovals map,
* so the same ToolCall UI handles both SDK and MCP approvals, and approvals
* survive ChatMessageList unmount/remount cycles via replayPendingApprovals().
*
* IMPORTANT: Call this from a component that stays mounted for the lifetime of
* the AI panel (e.g. AIChatSidePanel), NOT from ChatMessageList which unmounts
* on tab switches.
*
* Returns an unsubscribe function.
*/
export function setupMcpApprovalBridge(): () => void {
const bridge = (window as unknown as {
netcatty?: {
onMcpApprovalRequest?: (cb: (payload: {
approvalId: string;
toolName: string;
args: Record<string, unknown>;
chatSessionId?: string;
}) => void) => () => void;
onMcpApprovalCleared?: (cb: (payload: {
approvalIds: string[];
}) => void) => () => void;
};
}).netcatty;
if (!bridge?.onMcpApprovalRequest) return () => {};
const unsubRequest = bridge.onMcpApprovalRequest((payload) => {
const request: ApprovalRequest = {
toolCallId: payload.approvalId,
toolName: payload.toolName,
args: payload.args,
chatSessionId: payload.chatSessionId,
capabilityId: resolveCapabilityId(payload.toolName),
source: 'mcp',
};
// Store in pendingApprovals so it survives unmount/remount
// The resolve is a no-op because MCP approval resolution goes through IPC
// (handled in resolveApproval when toolCallId starts with 'mcp_approval_')
if (!pendingApprovals.has(payload.approvalId)) {
pendingApprovals.set(payload.approvalId, {
resolve: () => {}, // no-op; real resolution is via IPC
request,
});
}
// Notify all UI listeners
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
});
// Subscribe to main-process approval cleared events (timeout, cancel)
// so stale approval cards are removed from the renderer UI.
const unsubCleared = bridge.onMcpApprovalCleared?.((payload) => {
const clearedIds: string[] = [];
for (const id of payload.approvalIds) {
if (pendingApprovals.has(id)) {
pendingApprovals.delete(id);
clearedIds.push(id);
}
}
if (clearedIds.length > 0) {
for (const cl of clearedListeners) {
try { cl(clearedIds); } catch { /* ignore */ }
}
}
});
return () => {
unsubRequest();
unsubCleared?.();
};
}