source dump of claude code
at main 859 lines 29 kB view raw
1import { feature } from 'bun:bundle' 2import type { 3 ElicitResult, 4 JSONRPCMessage, 5} from '@modelcontextprotocol/sdk/types.js' 6import { randomUUID } from 'crypto' 7import type { AssistantMessage } from 'src//types/message.js' 8import type { 9 HookInput, 10 HookJSONOutput, 11 PermissionUpdate, 12 SDKMessage, 13 SDKUserMessage, 14} from 'src/entrypoints/agentSdkTypes.js' 15import { SDKControlElicitationResponseSchema } from 'src/entrypoints/sdk/controlSchemas.js' 16import type { 17 SDKControlRequest, 18 SDKControlResponse, 19 StdinMessage, 20 StdoutMessage, 21} from 'src/entrypoints/sdk/controlTypes.js' 22import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js' 23import type { Tool, ToolUseContext } from 'src/Tool.js' 24import { type HookCallback, hookJSONOutputSchema } from 'src/types/hooks.js' 25import { logForDebugging } from 'src/utils/debug.js' 26import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js' 27import { AbortError } from 'src/utils/errors.js' 28import { 29 type Output as PermissionToolOutput, 30 permissionPromptToolResultToPermissionDecision, 31 outputSchema as permissionToolOutputSchema, 32} from 'src/utils/permissions/PermissionPromptToolResultSchema.js' 33import type { 34 PermissionDecision, 35 PermissionDecisionReason, 36} from 'src/utils/permissions/PermissionResult.js' 37import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js' 38import { writeToStdout } from 'src/utils/process.js' 39import { jsonStringify } from 'src/utils/slowOperations.js' 40import { z } from 'zod/v4' 41import { notifyCommandLifecycle } from '../utils/commandLifecycle.js' 42import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js' 43import { executePermissionRequestHooks } from '../utils/hooks.js' 44import { 45 applyPermissionUpdates, 46 persistPermissionUpdates, 47} from '../utils/permissions/PermissionUpdate.js' 48import { 49 notifySessionStateChanged, 50 type RequiresActionDetails, 51 type SessionExternalMetadata, 52} from '../utils/sessionState.js' 53import { jsonParse } from '../utils/slowOperations.js' 54import { Stream } from '../utils/stream.js' 55import { ndjsonSafeStringify } from './ndjsonSafeStringify.js' 56 57/** 58 * Synthetic tool name used when forwarding sandbox network permission 59 * requests via the can_use_tool control_request protocol. SDK hosts 60 * see this as a normal tool permission prompt. 61 */ 62export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess' 63 64function serializeDecisionReason( 65 reason: PermissionDecisionReason | undefined, 66): string | undefined { 67 if (!reason) { 68 return undefined 69 } 70 71 if ( 72 (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && 73 reason.type === 'classifier' 74 ) { 75 return reason.reason 76 } 77 switch (reason.type) { 78 case 'rule': 79 case 'mode': 80 case 'subcommandResults': 81 case 'permissionPromptTool': 82 return undefined 83 case 'hook': 84 case 'asyncAgent': 85 case 'sandboxOverride': 86 case 'workingDir': 87 case 'safetyCheck': 88 case 'other': 89 return reason.reason 90 } 91} 92 93function buildRequiresActionDetails( 94 tool: Tool, 95 input: Record<string, unknown>, 96 toolUseID: string, 97 requestId: string, 98): RequiresActionDetails { 99 // Per-tool summary methods may throw on malformed input; permission 100 // handling must not break because of a bad description. 101 let description: string 102 try { 103 description = 104 tool.getActivityDescription?.(input) ?? 105 tool.getToolUseSummary?.(input) ?? 106 tool.userFacingName(input) 107 } catch { 108 description = tool.name 109 } 110 return { 111 tool_name: tool.name, 112 action_description: description, 113 tool_use_id: toolUseID, 114 request_id: requestId, 115 input, 116 } 117} 118 119type PendingRequest<T> = { 120 resolve: (result: T) => void 121 reject: (error: unknown) => void 122 schema?: z.Schema 123 request: SDKControlRequest 124} 125 126/** 127 * Provides a structured way to read and write SDK messages from stdio, 128 * capturing the SDK protocol. 129 */ 130// Maximum number of resolved tool_use IDs to track. Once exceeded, the oldest 131// entry is evicted. This bounds memory in very long sessions while keeping 132// enough history to catch duplicate control_response deliveries. 133const MAX_RESOLVED_TOOL_USE_IDS = 1000 134 135export class StructuredIO { 136 readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage> 137 private readonly pendingRequests = new Map<string, PendingRequest<unknown>>() 138 139 // CCR external_metadata read back on worker start; null when the 140 // transport doesn't restore. Assigned by RemoteIO. 141 restoredWorkerState: Promise<SessionExternalMetadata | null> = 142 Promise.resolve(null) 143 144 private inputClosed = false 145 private unexpectedResponseCallback?: ( 146 response: SDKControlResponse, 147 ) => Promise<void> 148 149 // Tracks tool_use IDs that have been resolved through the normal permission 150 // flow (or aborted by a hook). When a duplicate control_response arrives 151 // after the original was already handled, this Set prevents the orphan 152 // handler from re-processing it — which would push duplicate assistant 153 // messages into mutableMessages and cause a 400 "tool_use ids must be unique" 154 // error from the API. 155 private readonly resolvedToolUseIds = new Set<string>() 156 private prependedLines: string[] = [] 157 private onControlRequestSent?: (request: SDKControlRequest) => void 158 private onControlRequestResolved?: (requestId: string) => void 159 160 // sendRequest() and print.ts both enqueue here; the drain loop is the 161 // only writer. Prevents control_request from overtaking queued stream_events. 162 readonly outbound = new Stream<StdoutMessage>() 163 164 constructor( 165 private readonly input: AsyncIterable<string>, 166 private readonly replayUserMessages?: boolean, 167 ) { 168 this.input = input 169 this.structuredInput = this.read() 170 } 171 172 /** 173 * Records a tool_use ID as resolved so that late/duplicate control_response 174 * messages for the same tool are ignored by the orphan handler. 175 */ 176 private trackResolvedToolUseId(request: SDKControlRequest): void { 177 if (request.request.subtype === 'can_use_tool') { 178 this.resolvedToolUseIds.add(request.request.tool_use_id) 179 if (this.resolvedToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) { 180 // Evict the oldest entry (Sets iterate in insertion order) 181 const first = this.resolvedToolUseIds.values().next().value 182 if (first !== undefined) { 183 this.resolvedToolUseIds.delete(first) 184 } 185 } 186 } 187 } 188 189 /** Flush pending internal events. No-op for non-remote IO. Overridden by RemoteIO. */ 190 flushInternalEvents(): Promise<void> { 191 return Promise.resolve() 192 } 193 194 /** Internal-event queue depth. Overridden by RemoteIO; zero otherwise. */ 195 get internalEventsPending(): number { 196 return 0 197 } 198 199 /** 200 * Queue a user turn to be yielded before the next message from this.input. 201 * Works before iteration starts and mid-stream — read() re-checks 202 * prependedLines between each yielded message. 203 */ 204 prependUserMessage(content: string): void { 205 this.prependedLines.push( 206 jsonStringify({ 207 type: 'user', 208 session_id: '', 209 message: { role: 'user', content }, 210 parent_tool_use_id: null, 211 } satisfies SDKUserMessage) + '\n', 212 ) 213 } 214 215 private async *read() { 216 let content = '' 217 218 // Called once before for-await (an empty this.input otherwise skips the 219 // loop body entirely), then again per block. prependedLines re-check is 220 // inside the while so a prepend pushed between two messages in the SAME 221 // block still lands first. 222 const splitAndProcess = async function* (this: StructuredIO) { 223 for (;;) { 224 if (this.prependedLines.length > 0) { 225 content = this.prependedLines.join('') + content 226 this.prependedLines = [] 227 } 228 const newline = content.indexOf('\n') 229 if (newline === -1) break 230 const line = content.slice(0, newline) 231 content = content.slice(newline + 1) 232 const message = await this.processLine(line) 233 if (message) { 234 logForDiagnosticsNoPII('info', 'cli_stdin_message_parsed', { 235 type: message.type, 236 }) 237 yield message 238 } 239 } 240 }.bind(this) 241 242 yield* splitAndProcess() 243 244 for await (const block of this.input) { 245 content += block 246 yield* splitAndProcess() 247 } 248 if (content) { 249 const message = await this.processLine(content) 250 if (message) { 251 yield message 252 } 253 } 254 this.inputClosed = true 255 for (const request of this.pendingRequests.values()) { 256 // Reject all pending requests if the input stream 257 request.reject( 258 new Error('Tool permission stream closed before response received'), 259 ) 260 } 261 } 262 263 getPendingPermissionRequests() { 264 return Array.from(this.pendingRequests.values()) 265 .map(entry => entry.request) 266 .filter(pr => pr.request.subtype === 'can_use_tool') 267 } 268 269 setUnexpectedResponseCallback( 270 callback: (response: SDKControlResponse) => Promise<void>, 271 ): void { 272 this.unexpectedResponseCallback = callback 273 } 274 275 /** 276 * Inject a control_response message to resolve a pending permission request. 277 * Used by the bridge to feed permission responses from claude.ai into the 278 * SDK permission flow. 279 * 280 * Also sends a control_cancel_request to the SDK consumer so its canUseTool 281 * callback is aborted via the signal — otherwise the callback hangs. 282 */ 283 injectControlResponse(response: SDKControlResponse): void { 284 const requestId = response.response?.request_id 285 if (!requestId) return 286 const request = this.pendingRequests.get(requestId) 287 if (!request) return 288 this.trackResolvedToolUseId(request.request) 289 this.pendingRequests.delete(requestId) 290 // Cancel the SDK consumer's canUseTool callback — the bridge won. 291 void this.write({ 292 type: 'control_cancel_request', 293 request_id: requestId, 294 }) 295 if (response.response.subtype === 'error') { 296 request.reject(new Error(response.response.error)) 297 } else { 298 const result = response.response.response 299 if (request.schema) { 300 try { 301 request.resolve(request.schema.parse(result)) 302 } catch (error) { 303 request.reject(error) 304 } 305 } else { 306 request.resolve({}) 307 } 308 } 309 } 310 311 /** 312 * Register a callback invoked whenever a can_use_tool control_request 313 * is written to stdout. Used by the bridge to forward permission 314 * requests to claude.ai. 315 */ 316 setOnControlRequestSent( 317 callback: ((request: SDKControlRequest) => void) | undefined, 318 ): void { 319 this.onControlRequestSent = callback 320 } 321 322 /** 323 * Register a callback invoked when a can_use_tool control_response arrives 324 * from the SDK consumer (via stdin). Used by the bridge to cancel the 325 * stale permission prompt on claude.ai when the SDK consumer wins the race. 326 */ 327 setOnControlRequestResolved( 328 callback: ((requestId: string) => void) | undefined, 329 ): void { 330 this.onControlRequestResolved = callback 331 } 332 333 private async processLine( 334 line: string, 335 ): Promise<StdinMessage | SDKMessage | undefined> { 336 // Skip empty lines (e.g. from double newlines in piped stdin) 337 if (!line) { 338 return undefined 339 } 340 try { 341 const message = normalizeControlMessageKeys(jsonParse(line)) as 342 | StdinMessage 343 | SDKMessage 344 if (message.type === 'keep_alive') { 345 // Silently ignore keep-alive messages 346 return undefined 347 } 348 if (message.type === 'update_environment_variables') { 349 // Apply environment variable updates directly to process.env. 350 // Used by bridge session runner for auth token refresh 351 // (CLAUDE_CODE_SESSION_ACCESS_TOKEN) which must be readable 352 // by the REPL process itself, not just child Bash commands. 353 const keys = Object.keys(message.variables) 354 for (const [key, value] of Object.entries(message.variables)) { 355 process.env[key] = value 356 } 357 logForDebugging( 358 `[structuredIO] applied update_environment_variables: ${keys.join(', ')}`, 359 ) 360 return undefined 361 } 362 if (message.type === 'control_response') { 363 // Close lifecycle for every control_response, including duplicates 364 // and orphans — orphans don't yield to print.ts's main loop, so this 365 // is the only path that sees them. uuid is server-injected into the 366 // payload. 367 const uuid = 368 'uuid' in message && typeof message.uuid === 'string' 369 ? message.uuid 370 : undefined 371 if (uuid) { 372 notifyCommandLifecycle(uuid, 'completed') 373 } 374 const request = this.pendingRequests.get(message.response.request_id) 375 if (!request) { 376 // Check if this tool_use was already resolved through the normal 377 // permission flow. Duplicate control_response deliveries (e.g. from 378 // WebSocket reconnects) arrive after the original was handled, and 379 // re-processing them would push duplicate assistant messages into 380 // the conversation, causing API 400 errors. 381 const responsePayload = 382 message.response.subtype === 'success' 383 ? message.response.response 384 : undefined 385 const toolUseID = responsePayload?.toolUseID 386 if ( 387 typeof toolUseID === 'string' && 388 this.resolvedToolUseIds.has(toolUseID) 389 ) { 390 logForDebugging( 391 `Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`, 392 ) 393 return undefined 394 } 395 if (this.unexpectedResponseCallback) { 396 await this.unexpectedResponseCallback(message) 397 } 398 return undefined // Ignore responses for requests we don't know about 399 } 400 this.trackResolvedToolUseId(request.request) 401 this.pendingRequests.delete(message.response.request_id) 402 // Notify the bridge when the SDK consumer resolves a can_use_tool 403 // request, so it can cancel the stale permission prompt on claude.ai. 404 if ( 405 request.request.request.subtype === 'can_use_tool' && 406 this.onControlRequestResolved 407 ) { 408 this.onControlRequestResolved(message.response.request_id) 409 } 410 411 if (message.response.subtype === 'error') { 412 request.reject(new Error(message.response.error)) 413 return undefined 414 } 415 const result = message.response.response 416 if (request.schema) { 417 try { 418 request.resolve(request.schema.parse(result)) 419 } catch (error) { 420 request.reject(error) 421 } 422 } else { 423 request.resolve({}) 424 } 425 // Propagate control responses when replay is enabled 426 if (this.replayUserMessages) { 427 return message 428 } 429 return undefined 430 } 431 if ( 432 message.type !== 'user' && 433 message.type !== 'control_request' && 434 message.type !== 'assistant' && 435 message.type !== 'system' 436 ) { 437 logForDebugging(`Ignoring unknown message type: ${message.type}`, { 438 level: 'warn', 439 }) 440 return undefined 441 } 442 if (message.type === 'control_request') { 443 if (!message.request) { 444 exitWithMessage(`Error: Missing request on control_request`) 445 } 446 return message 447 } 448 if (message.type === 'assistant' || message.type === 'system') { 449 return message 450 } 451 if (message.message.role !== 'user') { 452 exitWithMessage( 453 `Error: Expected message role 'user', got '${message.message.role}'`, 454 ) 455 } 456 return message 457 } catch (error) { 458 // biome-ignore lint/suspicious/noConsole:: intentional console output 459 console.error(`Error parsing streaming input line: ${line}: ${error}`) 460 // eslint-disable-next-line custom-rules/no-process-exit 461 process.exit(1) 462 } 463 } 464 465 async write(message: StdoutMessage): Promise<void> { 466 writeToStdout(ndjsonSafeStringify(message) + '\n') 467 } 468 469 private async sendRequest<Response>( 470 request: SDKControlRequest['request'], 471 schema: z.Schema, 472 signal?: AbortSignal, 473 requestId: string = randomUUID(), 474 ): Promise<Response> { 475 const message: SDKControlRequest = { 476 type: 'control_request', 477 request_id: requestId, 478 request, 479 } 480 if (this.inputClosed) { 481 throw new Error('Stream closed') 482 } 483 if (signal?.aborted) { 484 throw new Error('Request aborted') 485 } 486 this.outbound.enqueue(message) 487 if (request.subtype === 'can_use_tool' && this.onControlRequestSent) { 488 this.onControlRequestSent(message) 489 } 490 const aborted = () => { 491 this.outbound.enqueue({ 492 type: 'control_cancel_request', 493 request_id: requestId, 494 }) 495 // Immediately reject the outstanding promise, without 496 // waiting for the host to acknowledge the cancellation. 497 const request = this.pendingRequests.get(requestId) 498 if (request) { 499 // Track the tool_use ID as resolved before rejecting, so that a 500 // late response from the host is ignored by the orphan handler. 501 this.trackResolvedToolUseId(request.request) 502 request.reject(new AbortError()) 503 } 504 } 505 if (signal) { 506 signal.addEventListener('abort', aborted, { 507 once: true, 508 }) 509 } 510 try { 511 return await new Promise<Response>((resolve, reject) => { 512 this.pendingRequests.set(requestId, { 513 request: { 514 type: 'control_request', 515 request_id: requestId, 516 request, 517 }, 518 resolve: result => { 519 resolve(result as Response) 520 }, 521 reject, 522 schema, 523 }) 524 }) 525 } finally { 526 if (signal) { 527 signal.removeEventListener('abort', aborted) 528 } 529 this.pendingRequests.delete(requestId) 530 } 531 } 532 533 createCanUseTool( 534 onPermissionPrompt?: (details: RequiresActionDetails) => void, 535 ): CanUseToolFn { 536 return async ( 537 tool: Tool, 538 input: { [key: string]: unknown }, 539 toolUseContext: ToolUseContext, 540 assistantMessage: AssistantMessage, 541 toolUseID: string, 542 forceDecision?: PermissionDecision, 543 ): Promise<PermissionDecision> => { 544 const mainPermissionResult = 545 forceDecision ?? 546 (await hasPermissionsToUseTool( 547 tool, 548 input, 549 toolUseContext, 550 assistantMessage, 551 toolUseID, 552 )) 553 // If the tool is allowed or denied, return the result 554 if ( 555 mainPermissionResult.behavior === 'allow' || 556 mainPermissionResult.behavior === 'deny' 557 ) { 558 return mainPermissionResult 559 } 560 561 // Run PermissionRequest hooks in parallel with the SDK permission 562 // prompt. In the terminal CLI, hooks race against the interactive 563 // prompt so that e.g. a hook with --delay 20 doesn't block the UI. 564 // We need the same behavior here: the SDK host (VS Code, etc.) shows 565 // its permission dialog immediately while hooks run in the background. 566 // Whichever resolves first wins; the loser is cancelled/ignored. 567 568 // AbortController used to cancel the SDK request if a hook decides first 569 const hookAbortController = new AbortController() 570 const parentSignal = toolUseContext.abortController.signal 571 // Forward parent abort to our local controller 572 const onParentAbort = () => hookAbortController.abort() 573 parentSignal.addEventListener('abort', onParentAbort, { once: true }) 574 575 try { 576 // Start the hook evaluation (runs in background) 577 const hookPromise = executePermissionRequestHooksForSDK( 578 tool.name, 579 toolUseID, 580 input, 581 toolUseContext, 582 mainPermissionResult.suggestions, 583 ).then(decision => ({ source: 'hook' as const, decision })) 584 585 // Start the SDK permission prompt immediately (don't wait for hooks) 586 const requestId = randomUUID() 587 onPermissionPrompt?.( 588 buildRequiresActionDetails(tool, input, toolUseID, requestId), 589 ) 590 const sdkPromise = this.sendRequest<PermissionToolOutput>( 591 { 592 subtype: 'can_use_tool', 593 tool_name: tool.name, 594 input, 595 permission_suggestions: mainPermissionResult.suggestions, 596 blocked_path: mainPermissionResult.blockedPath, 597 decision_reason: serializeDecisionReason( 598 mainPermissionResult.decisionReason, 599 ), 600 tool_use_id: toolUseID, 601 agent_id: toolUseContext.agentId, 602 }, 603 permissionToolOutputSchema(), 604 hookAbortController.signal, 605 requestId, 606 ).then(result => ({ source: 'sdk' as const, result })) 607 608 // Race: hook completion vs SDK prompt response. 609 // The hook promise always resolves (never rejects), returning 610 // undefined if no hook made a decision. 611 const winner = await Promise.race([hookPromise, sdkPromise]) 612 613 if (winner.source === 'hook') { 614 if (winner.decision) { 615 // Hook decided — abort the pending SDK request. 616 // Suppress the expected AbortError rejection from sdkPromise. 617 sdkPromise.catch(() => {}) 618 hookAbortController.abort() 619 return winner.decision 620 } 621 // Hook passed through (no decision) — wait for the SDK prompt 622 const sdkResult = await sdkPromise 623 return permissionPromptToolResultToPermissionDecision( 624 sdkResult.result, 625 tool, 626 input, 627 toolUseContext, 628 ) 629 } 630 631 // SDK prompt responded first — use its result (hook still running 632 // in background but its result will be ignored) 633 return permissionPromptToolResultToPermissionDecision( 634 winner.result, 635 tool, 636 input, 637 toolUseContext, 638 ) 639 } catch (error) { 640 return permissionPromptToolResultToPermissionDecision( 641 { 642 behavior: 'deny', 643 message: `Tool permission request failed: ${error}`, 644 toolUseID, 645 }, 646 tool, 647 input, 648 toolUseContext, 649 ) 650 } finally { 651 // Only transition back to 'running' if no other permission prompts 652 // are pending (concurrent tool execution can have multiple in-flight). 653 if (this.getPendingPermissionRequests().length === 0) { 654 notifySessionStateChanged('running') 655 } 656 parentSignal.removeEventListener('abort', onParentAbort) 657 } 658 } 659 } 660 661 createHookCallback(callbackId: string, timeout?: number): HookCallback { 662 return { 663 type: 'callback', 664 timeout, 665 callback: async ( 666 input: HookInput, 667 toolUseID: string | null, 668 abort: AbortSignal | undefined, 669 ): Promise<HookJSONOutput> => { 670 try { 671 const result = await this.sendRequest<HookJSONOutput>( 672 { 673 subtype: 'hook_callback', 674 callback_id: callbackId, 675 input, 676 tool_use_id: toolUseID || undefined, 677 }, 678 hookJSONOutputSchema(), 679 abort, 680 ) 681 return result 682 } catch (error) { 683 // biome-ignore lint/suspicious/noConsole:: intentional console output 684 console.error(`Error in hook callback ${callbackId}:`, error) 685 return {} 686 } 687 }, 688 } 689 } 690 691 /** 692 * Sends an elicitation request to the SDK consumer and returns the response. 693 */ 694 async handleElicitation( 695 serverName: string, 696 message: string, 697 requestedSchema?: Record<string, unknown>, 698 signal?: AbortSignal, 699 mode?: 'form' | 'url', 700 url?: string, 701 elicitationId?: string, 702 ): Promise<ElicitResult> { 703 try { 704 const result = await this.sendRequest<ElicitResult>( 705 { 706 subtype: 'elicitation', 707 mcp_server_name: serverName, 708 message, 709 mode, 710 url, 711 elicitation_id: elicitationId, 712 requested_schema: requestedSchema, 713 }, 714 SDKControlElicitationResponseSchema(), 715 signal, 716 ) 717 return result 718 } catch { 719 return { action: 'cancel' as const } 720 } 721 } 722 723 /** 724 * Creates a SandboxAskCallback that forwards sandbox network permission 725 * requests to the SDK host as can_use_tool control_requests. 726 * 727 * This piggybacks on the existing can_use_tool protocol with a synthetic 728 * tool name so that SDK hosts (VS Code, CCR, etc.) can prompt the user 729 * for network access without requiring a new protocol subtype. 730 */ 731 createSandboxAskCallback(): (hostPattern: { 732 host: string 733 port?: number 734 }) => Promise<boolean> { 735 return async (hostPattern): Promise<boolean> => { 736 try { 737 const result = await this.sendRequest<PermissionToolOutput>( 738 { 739 subtype: 'can_use_tool', 740 tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME, 741 input: { host: hostPattern.host }, 742 tool_use_id: randomUUID(), 743 description: `Allow network connection to ${hostPattern.host}?`, 744 }, 745 permissionToolOutputSchema(), 746 ) 747 return result.behavior === 'allow' 748 } catch { 749 // If the request fails (stream closed, abort, etc.), deny the connection 750 return false 751 } 752 } 753 } 754 755 /** 756 * Sends an MCP message to an SDK server and waits for the response 757 */ 758 async sendMcpMessage( 759 serverName: string, 760 message: JSONRPCMessage, 761 ): Promise<JSONRPCMessage> { 762 const response = await this.sendRequest<{ mcp_response: JSONRPCMessage }>( 763 { 764 subtype: 'mcp_message', 765 server_name: serverName, 766 message, 767 }, 768 z.object({ 769 mcp_response: z.any() as z.Schema<JSONRPCMessage>, 770 }), 771 ) 772 return response.mcp_response 773 } 774} 775 776function exitWithMessage(message: string): never { 777 // biome-ignore lint/suspicious/noConsole:: intentional console output 778 console.error(message) 779 // eslint-disable-next-line custom-rules/no-process-exit 780 process.exit(1) 781} 782 783/** 784 * Execute PermissionRequest hooks and return a decision if one is made. 785 * Returns undefined if no hook made a decision. 786 */ 787async function executePermissionRequestHooksForSDK( 788 toolName: string, 789 toolUseID: string, 790 input: Record<string, unknown>, 791 toolUseContext: ToolUseContext, 792 suggestions: PermissionUpdate[] | undefined, 793): Promise<PermissionDecision | undefined> { 794 const appState = toolUseContext.getAppState() 795 const permissionMode = appState.toolPermissionContext.mode 796 797 // Iterate directly over the generator instead of using `all` 798 const hookGenerator = executePermissionRequestHooks( 799 toolName, 800 toolUseID, 801 input, 802 toolUseContext, 803 permissionMode, 804 suggestions, 805 toolUseContext.abortController.signal, 806 ) 807 808 for await (const hookResult of hookGenerator) { 809 if ( 810 hookResult.permissionRequestResult && 811 (hookResult.permissionRequestResult.behavior === 'allow' || 812 hookResult.permissionRequestResult.behavior === 'deny') 813 ) { 814 const decision = hookResult.permissionRequestResult 815 if (decision.behavior === 'allow') { 816 const finalInput = decision.updatedInput || input 817 818 // Apply permission updates if provided by hook ("always allow") 819 const permissionUpdates = decision.updatedPermissions ?? [] 820 if (permissionUpdates.length > 0) { 821 persistPermissionUpdates(permissionUpdates) 822 const currentAppState = toolUseContext.getAppState() 823 const updatedContext = applyPermissionUpdates( 824 currentAppState.toolPermissionContext, 825 permissionUpdates, 826 ) 827 // Update permission context via setAppState 828 toolUseContext.setAppState(prev => { 829 if (prev.toolPermissionContext === updatedContext) return prev 830 return { ...prev, toolPermissionContext: updatedContext } 831 }) 832 } 833 834 return { 835 behavior: 'allow', 836 updatedInput: finalInput, 837 userModified: false, 838 decisionReason: { 839 type: 'hook', 840 hookName: 'PermissionRequest', 841 }, 842 } 843 } else { 844 // Hook denied the permission 845 return { 846 behavior: 'deny', 847 message: 848 decision.message || 'Permission denied by PermissionRequest hook', 849 decisionReason: { 850 type: 'hook', 851 hookName: 'PermissionRequest', 852 }, 853 } 854 } 855 } 856 } 857 858 return undefined 859}