Files
NetMesh/infrastructure/ai/shared/approvalGate.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

529 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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?.();
};
}