source dump of claude code
at main 536 lines 20 kB view raw
1import { feature } from 'bun:bundle' 2import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 3import { randomUUID } from 'crypto' 4import { logForDebugging } from 'src/utils/debug.js' 5import { getAllowedChannels } from '../../../bootstrap/state.js' 6import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js' 7import { getTerminalFocused } from '../../../ink/terminal-focus-state.js' 8import { 9 CHANNEL_PERMISSION_REQUEST_METHOD, 10 type ChannelPermissionRequestParams, 11 findChannelEntry, 12} from '../../../services/mcp/channelNotification.js' 13import type { ChannelPermissionCallbacks } from '../../../services/mcp/channelPermissions.js' 14import { 15 filterPermissionRelayClients, 16 shortRequestId, 17 truncateForPreview, 18} from '../../../services/mcp/channelPermissions.js' 19import { executeAsyncClassifierCheck } from '../../../tools/BashTool/bashPermissions.js' 20import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js' 21import { 22 clearClassifierChecking, 23 setClassifierApproval, 24 setClassifierChecking, 25 setYoloClassifierApproval, 26} from '../../../utils/classifierApprovals.js' 27import { errorMessage } from '../../../utils/errors.js' 28import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js' 29import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' 30import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js' 31import type { PermissionContext } from '../PermissionContext.js' 32import { createResolveOnce } from '../PermissionContext.js' 33 34type InteractivePermissionParams = { 35 ctx: PermissionContext 36 description: string 37 result: PermissionDecision & { behavior: 'ask' } 38 awaitAutomatedChecksBeforeDialog: boolean | undefined 39 bridgeCallbacks?: BridgePermissionCallbacks 40 channelCallbacks?: ChannelPermissionCallbacks 41} 42 43/** 44 * Handles the interactive (main-agent) permission flow. 45 * 46 * Pushes a ToolUseConfirm entry to the confirm queue with callbacks: 47 * onAbort, onAllow, onReject, recheckPermission, onUserInteraction. 48 * 49 * Runs permission hooks and bash classifier checks asynchronously in the 50 * background, racing them against user interaction. Uses a resolve-once 51 * guard and `userInteracted` flag to prevent multiple resolutions. 52 * 53 * This function does NOT return a Promise -- it sets up callbacks that 54 * eventually call `resolve()` to resolve the outer promise owned by 55 * the caller. 56 */ 57function handleInteractivePermission( 58 params: InteractivePermissionParams, 59 resolve: (decision: PermissionDecision) => void, 60): void { 61 const { 62 ctx, 63 description, 64 result, 65 awaitAutomatedChecksBeforeDialog, 66 bridgeCallbacks, 67 channelCallbacks, 68 } = params 69 70 const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve) 71 let userInteracted = false 72 let checkmarkTransitionTimer: ReturnType<typeof setTimeout> | undefined 73 // Hoisted so onDismissCheckmark (Esc during checkmark window) can also 74 // remove the abort listener — not just the timer callback. 75 let checkmarkAbortHandler: (() => void) | undefined 76 const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined 77 // Hoisted so local/hook/classifier wins can remove the pending channel 78 // entry. No "tell remote to dismiss" equivalent — the text sits in your 79 // phone, and a stale "yes abc123" after local-resolve falls through 80 // tryConsumeReply (entry gone) and gets enqueued as normal chat. 81 let channelUnsubscribe: (() => void) | undefined 82 83 const permissionPromptStartTimeMs = Date.now() 84 const displayInput = result.updatedInput ?? ctx.input 85 86 function clearClassifierIndicator(): void { 87 if (feature('BASH_CLASSIFIER')) { 88 ctx.updateQueueItem({ classifierCheckInProgress: false }) 89 } 90 } 91 92 ctx.pushToQueue({ 93 assistantMessage: ctx.assistantMessage, 94 tool: ctx.tool, 95 description, 96 input: displayInput, 97 toolUseContext: ctx.toolUseContext, 98 toolUseID: ctx.toolUseID, 99 permissionResult: result, 100 permissionPromptStartTimeMs, 101 ...(feature('BASH_CLASSIFIER') 102 ? { 103 classifierCheckInProgress: 104 !!result.pendingClassifierCheck && 105 !awaitAutomatedChecksBeforeDialog, 106 } 107 : {}), 108 onUserInteraction() { 109 // Called when user starts interacting with the permission dialog 110 // (e.g., arrow keys, tab, typing feedback) 111 // Hide the classifier indicator since auto-approve is no longer possible 112 // 113 // Grace period: ignore interactions in the first 200ms to prevent 114 // accidental keypresses from canceling the classifier prematurely 115 const GRACE_PERIOD_MS = 200 116 if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) { 117 return 118 } 119 userInteracted = true 120 clearClassifierChecking(ctx.toolUseID) 121 clearClassifierIndicator() 122 }, 123 onDismissCheckmark() { 124 if (checkmarkTransitionTimer) { 125 clearTimeout(checkmarkTransitionTimer) 126 checkmarkTransitionTimer = undefined 127 if (checkmarkAbortHandler) { 128 ctx.toolUseContext.abortController.signal.removeEventListener( 129 'abort', 130 checkmarkAbortHandler, 131 ) 132 checkmarkAbortHandler = undefined 133 } 134 ctx.removeFromQueue() 135 } 136 }, 137 onAbort() { 138 if (!claim()) return 139 if (bridgeCallbacks && bridgeRequestId) { 140 bridgeCallbacks.sendResponse(bridgeRequestId, { 141 behavior: 'deny', 142 message: 'User aborted', 143 }) 144 bridgeCallbacks.cancelRequest(bridgeRequestId) 145 } 146 channelUnsubscribe?.() 147 ctx.logCancelled() 148 ctx.logDecision( 149 { decision: 'reject', source: { type: 'user_abort' } }, 150 { permissionPromptStartTimeMs }, 151 ) 152 resolveOnce(ctx.cancelAndAbort(undefined, true)) 153 }, 154 async onAllow( 155 updatedInput, 156 permissionUpdates: PermissionUpdate[], 157 feedback?: string, 158 contentBlocks?: ContentBlockParam[], 159 ) { 160 if (!claim()) return // atomic check-and-mark before await 161 162 if (bridgeCallbacks && bridgeRequestId) { 163 bridgeCallbacks.sendResponse(bridgeRequestId, { 164 behavior: 'allow', 165 updatedInput, 166 updatedPermissions: permissionUpdates, 167 }) 168 bridgeCallbacks.cancelRequest(bridgeRequestId) 169 } 170 channelUnsubscribe?.() 171 172 resolveOnce( 173 await ctx.handleUserAllow( 174 updatedInput, 175 permissionUpdates, 176 feedback, 177 permissionPromptStartTimeMs, 178 contentBlocks, 179 result.decisionReason, 180 ), 181 ) 182 }, 183 onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) { 184 if (!claim()) return 185 186 if (bridgeCallbacks && bridgeRequestId) { 187 bridgeCallbacks.sendResponse(bridgeRequestId, { 188 behavior: 'deny', 189 message: feedback ?? 'User denied permission', 190 }) 191 bridgeCallbacks.cancelRequest(bridgeRequestId) 192 } 193 channelUnsubscribe?.() 194 195 ctx.logDecision( 196 { 197 decision: 'reject', 198 source: { type: 'user_reject', hasFeedback: !!feedback }, 199 }, 200 { permissionPromptStartTimeMs }, 201 ) 202 resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks)) 203 }, 204 async recheckPermission() { 205 if (isResolved()) return 206 const freshResult = await hasPermissionsToUseTool( 207 ctx.tool, 208 ctx.input, 209 ctx.toolUseContext, 210 ctx.assistantMessage, 211 ctx.toolUseID, 212 ) 213 if (freshResult.behavior === 'allow') { 214 // claim() (atomic check-and-mark), not isResolved() — the async 215 // hasPermissionsToUseTool call above opens a window where CCR 216 // could have responded in flight. Matches onAllow/onReject/hook 217 // paths. cancelRequest tells CCR to dismiss its prompt — without 218 // it, the web UI shows a stale prompt for a tool that's already 219 // executing (particularly visible when recheck is triggered by 220 // a CCR-initiated mode switch, the very case this callback exists 221 // for after useReplBridge started calling it). 222 if (!claim()) return 223 if (bridgeCallbacks && bridgeRequestId) { 224 bridgeCallbacks.cancelRequest(bridgeRequestId) 225 } 226 channelUnsubscribe?.() 227 ctx.removeFromQueue() 228 ctx.logDecision({ decision: 'accept', source: 'config' }) 229 resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input)) 230 } 231 }, 232 }) 233 234 // Race 4: Bridge permission response from CCR (claude.ai) 235 // When the bridge is connected, send the permission request to CCR and 236 // subscribe for a response. Whichever side (CLI or CCR) responds first 237 // wins via claim(). 238 // 239 // All tools are forwarded — CCR's generic allow/deny modal handles any 240 // tool, and can return `updatedInput` when it has a dedicated renderer 241 // (e.g. plan edit). Tools whose local dialog injects fields (ReviewArtifact 242 // `selected`, AskUserQuestion `answers`) tolerate the field being missing 243 // so generic remote approval degrades gracefully instead of throwing. 244 if (bridgeCallbacks && bridgeRequestId) { 245 bridgeCallbacks.sendRequest( 246 bridgeRequestId, 247 ctx.tool.name, 248 displayInput, 249 ctx.toolUseID, 250 description, 251 result.suggestions, 252 result.blockedPath, 253 ) 254 255 const signal = ctx.toolUseContext.abortController.signal 256 const unsubscribe = bridgeCallbacks.onResponse( 257 bridgeRequestId, 258 response => { 259 if (!claim()) return // Local user/hook/classifier already responded 260 signal.removeEventListener('abort', unsubscribe) 261 clearClassifierChecking(ctx.toolUseID) 262 clearClassifierIndicator() 263 ctx.removeFromQueue() 264 channelUnsubscribe?.() 265 266 if (response.behavior === 'allow') { 267 if (response.updatedPermissions?.length) { 268 void ctx.persistPermissions(response.updatedPermissions) 269 } 270 ctx.logDecision( 271 { 272 decision: 'accept', 273 source: { 274 type: 'user', 275 permanent: !!response.updatedPermissions?.length, 276 }, 277 }, 278 { permissionPromptStartTimeMs }, 279 ) 280 resolveOnce(ctx.buildAllow(response.updatedInput ?? displayInput)) 281 } else { 282 ctx.logDecision( 283 { 284 decision: 'reject', 285 source: { 286 type: 'user_reject', 287 hasFeedback: !!response.message, 288 }, 289 }, 290 { permissionPromptStartTimeMs }, 291 ) 292 resolveOnce(ctx.cancelAndAbort(response.message)) 293 } 294 }, 295 ) 296 297 signal.addEventListener('abort', unsubscribe, { once: true }) 298 } 299 300 // Channel permission relay — races alongside the bridge block above. Send a 301 // permission prompt to every active channel (Telegram, iMessage, etc.) via 302 // its MCP send_message tool, then race the reply against local/bridge/hook/ 303 // classifier. The inbound "yes abc123" is intercepted in the notification 304 // handler (useManageMCPConnections.ts) BEFORE enqueue, so it never reaches 305 // Claude as a conversation turn. 306 // 307 // Unlike the bridge block, this still guards on `requiresUserInteraction` — 308 // channel replies are pure yes/no with no `updatedInput` path. In practice 309 // the guard is dead code today: all three `requiresUserInteraction` tools 310 // (ExitPlanMode, AskUserQuestion, ReviewArtifact) return `isEnabled()===false` 311 // when channels are configured, so they never reach this handler. 312 // 313 // Fire-and-forget send: if callTool fails (channel down, tool missing), 314 // the subscription never fires and another racer wins. Graceful degradation 315 // — the local dialog is always there as the floor. 316 if ( 317 (feature('KAIROS') || feature('KAIROS_CHANNELS')) && 318 channelCallbacks && 319 !ctx.tool.requiresUserInteraction?.() 320 ) { 321 const channelRequestId = shortRequestId(ctx.toolUseID) 322 const allowedChannels = getAllowedChannels() 323 const channelClients = filterPermissionRelayClients( 324 ctx.toolUseContext.getAppState().mcp.clients, 325 name => findChannelEntry(name, allowedChannels) !== undefined, 326 ) 327 328 if (channelClients.length > 0) { 329 // Outbound is structured too (Kenneth's symmetry ask) — server owns 330 // message formatting for its platform (Telegram markdown, iMessage 331 // rich text, Discord embed). CC sends the RAW parts; server composes. 332 // The old callTool('send_message', {text,content,message}) triple-key 333 // hack is gone — no more guessing which arg name each plugin takes. 334 const params: ChannelPermissionRequestParams = { 335 request_id: channelRequestId, 336 tool_name: ctx.tool.name, 337 description, 338 input_preview: truncateForPreview(displayInput), 339 } 340 341 for (const client of channelClients) { 342 if (client.type !== 'connected') continue // refine for TS 343 void client.client 344 .notification({ 345 method: CHANNEL_PERMISSION_REQUEST_METHOD, 346 params, 347 }) 348 .catch(e => { 349 logForDebugging( 350 `Channel permission_request failed for ${client.name}: ${errorMessage(e)}`, 351 { level: 'error' }, 352 ) 353 }) 354 } 355 356 const channelSignal = ctx.toolUseContext.abortController.signal 357 // Wrap so BOTH the map delete AND the abort-listener teardown happen 358 // at every call site. The 6 channelUnsubscribe?.() sites after local/ 359 // hook/classifier wins previously only deleted the map entry — the 360 // dead closure stayed registered on the session-scoped abort signal 361 // until the session ended. Not a functional bug (Map.delete is 362 // idempotent), but it held the closure alive. 363 const mapUnsub = channelCallbacks.onResponse( 364 channelRequestId, 365 response => { 366 if (!claim()) return // Another racer won 367 channelUnsubscribe?.() // both: map delete + listener remove 368 clearClassifierChecking(ctx.toolUseID) 369 clearClassifierIndicator() 370 ctx.removeFromQueue() 371 // Bridge is the other remote — tell it we're done. 372 if (bridgeCallbacks && bridgeRequestId) { 373 bridgeCallbacks.cancelRequest(bridgeRequestId) 374 } 375 376 if (response.behavior === 'allow') { 377 ctx.logDecision( 378 { 379 decision: 'accept', 380 source: { type: 'user', permanent: false }, 381 }, 382 { permissionPromptStartTimeMs }, 383 ) 384 resolveOnce(ctx.buildAllow(displayInput)) 385 } else { 386 ctx.logDecision( 387 { 388 decision: 'reject', 389 source: { type: 'user_reject', hasFeedback: false }, 390 }, 391 { permissionPromptStartTimeMs }, 392 ) 393 resolveOnce( 394 ctx.cancelAndAbort(`Denied via channel ${response.fromServer}`), 395 ) 396 } 397 }, 398 ) 399 channelUnsubscribe = () => { 400 mapUnsub() 401 channelSignal.removeEventListener('abort', channelUnsubscribe!) 402 } 403 404 channelSignal.addEventListener('abort', channelUnsubscribe, { 405 once: true, 406 }) 407 } 408 } 409 410 // Skip hooks if they were already awaited in the coordinator branch above 411 if (!awaitAutomatedChecksBeforeDialog) { 412 // Execute PermissionRequest hooks asynchronously 413 // If hook returns a decision before user responds, apply it 414 void (async () => { 415 if (isResolved()) return 416 const currentAppState = ctx.toolUseContext.getAppState() 417 const hookDecision = await ctx.runHooks( 418 currentAppState.toolPermissionContext.mode, 419 result.suggestions, 420 result.updatedInput, 421 permissionPromptStartTimeMs, 422 ) 423 if (!hookDecision || !claim()) return 424 if (bridgeCallbacks && bridgeRequestId) { 425 bridgeCallbacks.cancelRequest(bridgeRequestId) 426 } 427 channelUnsubscribe?.() 428 ctx.removeFromQueue() 429 resolveOnce(hookDecision) 430 })() 431 } 432 433 // Execute bash classifier check asynchronously (if applicable) 434 if ( 435 feature('BASH_CLASSIFIER') && 436 result.pendingClassifierCheck && 437 ctx.tool.name === BASH_TOOL_NAME && 438 !awaitAutomatedChecksBeforeDialog 439 ) { 440 // UI indicator for "classifier running" — set here (not in 441 // toolExecution.ts) so commands that auto-allow via prefix rules 442 // don't flash the indicator for a split second before allow returns. 443 setClassifierChecking(ctx.toolUseID) 444 void executeAsyncClassifierCheck( 445 result.pendingClassifierCheck, 446 ctx.toolUseContext.abortController.signal, 447 ctx.toolUseContext.options.isNonInteractiveSession, 448 { 449 shouldContinue: () => !isResolved() && !userInteracted, 450 onComplete: () => { 451 clearClassifierChecking(ctx.toolUseID) 452 clearClassifierIndicator() 453 }, 454 onAllow: decisionReason => { 455 if (!claim()) return 456 if (bridgeCallbacks && bridgeRequestId) { 457 bridgeCallbacks.cancelRequest(bridgeRequestId) 458 } 459 channelUnsubscribe?.() 460 clearClassifierChecking(ctx.toolUseID) 461 462 const matchedRule = 463 decisionReason.type === 'classifier' 464 ? (decisionReason.reason.match( 465 /^Allowed by prompt rule: "(.+)"$/, 466 )?.[1] ?? decisionReason.reason) 467 : undefined 468 469 // Show auto-approved transition with dimmed options 470 if (feature('TRANSCRIPT_CLASSIFIER')) { 471 ctx.updateQueueItem({ 472 classifierCheckInProgress: false, 473 classifierAutoApproved: true, 474 classifierMatchedRule: matchedRule, 475 }) 476 } 477 478 if ( 479 feature('TRANSCRIPT_CLASSIFIER') && 480 decisionReason.type === 'classifier' 481 ) { 482 if (decisionReason.classifier === 'auto-mode') { 483 setYoloClassifierApproval(ctx.toolUseID, decisionReason.reason) 484 } else if (matchedRule) { 485 setClassifierApproval(ctx.toolUseID, matchedRule) 486 } 487 } 488 489 ctx.logDecision( 490 { decision: 'accept', source: { type: 'classifier' } }, 491 { permissionPromptStartTimeMs }, 492 ) 493 resolveOnce(ctx.buildAllow(ctx.input, { decisionReason })) 494 495 // Keep checkmark visible, then remove dialog. 496 // 3s if terminal is focused (user can see it), 1s if not. 497 // User can dismiss early with Esc via onDismissCheckmark. 498 const signal = ctx.toolUseContext.abortController.signal 499 checkmarkAbortHandler = () => { 500 if (checkmarkTransitionTimer) { 501 clearTimeout(checkmarkTransitionTimer) 502 checkmarkTransitionTimer = undefined 503 // Sibling Bash error can fire this (StreamingToolExecutor 504 // cascades via siblingAbortController) — must drop the 505 // cosmetic ✓ dialog or it blocks the next queued item. 506 ctx.removeFromQueue() 507 } 508 } 509 const checkmarkMs = getTerminalFocused() ? 3000 : 1000 510 checkmarkTransitionTimer = setTimeout(() => { 511 checkmarkTransitionTimer = undefined 512 if (checkmarkAbortHandler) { 513 signal.removeEventListener('abort', checkmarkAbortHandler) 514 checkmarkAbortHandler = undefined 515 } 516 ctx.removeFromQueue() 517 }, checkmarkMs) 518 signal.addEventListener('abort', checkmarkAbortHandler, { 519 once: true, 520 }) 521 }, 522 }, 523 ).catch(error => { 524 // Log classifier API errors for debugging but don't propagate them as interruptions 525 // These errors can be network failures, rate limits, or model issues - not user cancellations 526 logForDebugging(`Async classifier check failed: ${errorMessage(error)}`, { 527 level: 'error', 528 }) 529 }) 530 } 531} 532 533// -- 534 535export { handleInteractivePermission } 536export type { InteractivePermissionParams }