Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
/**
|
||
* 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?.();
|
||
};
|
||
}
|