Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
20 kB · 536 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537import { feature } from 'bun:bundle'import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'import { randomUUID } from 'crypto'import { logForDebugging } from 'src/utils/debug.js'import { getAllowedChannels } from '../../../bootstrap/state.js'import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'import { getTerminalFocused } from '../../../ink/terminal-focus-state.js'import { CHANNEL_PERMISSION_REQUEST_METHOD, type ChannelPermissionRequestParams, findChannelEntry,} from '../../../services/mcp/channelNotification.js'import type { ChannelPermissionCallbacks } from '../../../services/mcp/channelPermissions.js'import { filterPermissionRelayClients, shortRequestId, truncateForPreview,} from '../../../services/mcp/channelPermissions.js'import { executeAsyncClassifierCheck } from '../../../tools/BashTool/bashPermissions.js'import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js'import { clearClassifierChecking, setClassifierApproval, setClassifierChecking, setYoloClassifierApproval,} from '../../../utils/classifierApprovals.js'import { errorMessage } from '../../../utils/errors.js'import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'import type { PermissionContext } from '../PermissionContext.js'import { createResolveOnce } from '../PermissionContext.js'
type InteractivePermissionParams = { ctx: PermissionContext description: string result: PermissionDecision & { behavior: 'ask' } awaitAutomatedChecksBeforeDialog: boolean | undefined bridgeCallbacks?: BridgePermissionCallbacks channelCallbacks?: ChannelPermissionCallbacks}
/** * Handles the interactive (main-agent) permission flow. * * Pushes a ToolUseConfirm entry to the confirm queue with callbacks: * onAbort, onAllow, onReject, recheckPermission, onUserInteraction. * * Runs permission hooks and bash classifier checks asynchronously in the * background, racing them against user interaction. Uses a resolve-once * guard and `userInteracted` flag to prevent multiple resolutions. * * This function does NOT return a Promise -- it sets up callbacks that * eventually call `resolve()` to resolve the outer promise owned by * the caller. */function handleInteractivePermission( params: InteractivePermissionParams, resolve: (decision: PermissionDecision) => void,): void { const { ctx, description, result, awaitAutomatedChecksBeforeDialog, bridgeCallbacks, channelCallbacks, } = params
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve) let userInteracted = false let checkmarkTransitionTimer: ReturnType<typeof setTimeout> | undefined // Hoisted so onDismissCheckmark (Esc during checkmark window) can also // remove the abort listener — not just the timer callback. let checkmarkAbortHandler: (() => void) | undefined const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined // Hoisted so local/hook/classifier wins can remove the pending channel // entry. No "tell remote to dismiss" equivalent — the text sits in your // phone, and a stale "yes abc123" after local-resolve falls through // tryConsumeReply (entry gone) and gets enqueued as normal chat. let channelUnsubscribe: (() => void) | undefined
const permissionPromptStartTimeMs = Date.now() const displayInput = result.updatedInput ?? ctx.input
function clearClassifierIndicator(): void { if (feature('BASH_CLASSIFIER')) { ctx.updateQueueItem({ classifierCheckInProgress: false }) } }
ctx.pushToQueue({ assistantMessage: ctx.assistantMessage, tool: ctx.tool, description, input: displayInput, toolUseContext: ctx.toolUseContext, toolUseID: ctx.toolUseID, permissionResult: result, permissionPromptStartTimeMs, ...(feature('BASH_CLASSIFIER') ? { classifierCheckInProgress: !!result.pendingClassifierCheck && !awaitAutomatedChecksBeforeDialog, } : {}), onUserInteraction() { // Called when user starts interacting with the permission dialog // (e.g., arrow keys, tab, typing feedback) // Hide the classifier indicator since auto-approve is no longer possible // // Grace period: ignore interactions in the first 200ms to prevent // accidental keypresses from canceling the classifier prematurely const GRACE_PERIOD_MS = 200 if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) { return } userInteracted = true clearClassifierChecking(ctx.toolUseID) clearClassifierIndicator() }, onDismissCheckmark() { if (checkmarkTransitionTimer) { clearTimeout(checkmarkTransitionTimer) checkmarkTransitionTimer = undefined if (checkmarkAbortHandler) { ctx.toolUseContext.abortController.signal.removeEventListener( 'abort', checkmarkAbortHandler, ) checkmarkAbortHandler = undefined } ctx.removeFromQueue() } }, onAbort() { if (!claim()) return if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.sendResponse(bridgeRequestId, { behavior: 'deny', message: 'User aborted', }) bridgeCallbacks.cancelRequest(bridgeRequestId) } channelUnsubscribe?.() ctx.logCancelled() ctx.logDecision( { decision: 'reject', source: { type: 'user_abort' } }, { permissionPromptStartTimeMs }, ) resolveOnce(ctx.cancelAndAbort(undefined, true)) }, async onAllow( updatedInput, permissionUpdates: PermissionUpdate[], feedback?: string, contentBlocks?: ContentBlockParam[], ) { if (!claim()) return // atomic check-and-mark before await
if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.sendResponse(bridgeRequestId, { behavior: 'allow', updatedInput, updatedPermissions: permissionUpdates, }) bridgeCallbacks.cancelRequest(bridgeRequestId) } channelUnsubscribe?.()
resolveOnce( await ctx.handleUserAllow( updatedInput, permissionUpdates, feedback, permissionPromptStartTimeMs, contentBlocks, result.decisionReason, ), ) }, onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) { if (!claim()) return
if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.sendResponse(bridgeRequestId, { behavior: 'deny', message: feedback ?? 'User denied permission', }) bridgeCallbacks.cancelRequest(bridgeRequestId) } channelUnsubscribe?.()
ctx.logDecision( { decision: 'reject', source: { type: 'user_reject', hasFeedback: !!feedback }, }, { permissionPromptStartTimeMs }, ) resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks)) }, async recheckPermission() { if (isResolved()) return const freshResult = await hasPermissionsToUseTool( ctx.tool, ctx.input, ctx.toolUseContext, ctx.assistantMessage, ctx.toolUseID, ) if (freshResult.behavior === 'allow') { // claim() (atomic check-and-mark), not isResolved() — the async // hasPermissionsToUseTool call above opens a window where CCR // could have responded in flight. Matches onAllow/onReject/hook // paths. cancelRequest tells CCR to dismiss its prompt — without // it, the web UI shows a stale prompt for a tool that's already // executing (particularly visible when recheck is triggered by // a CCR-initiated mode switch, the very case this callback exists // for after useReplBridge started calling it). if (!claim()) return if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.cancelRequest(bridgeRequestId) } channelUnsubscribe?.() ctx.removeFromQueue() ctx.logDecision({ decision: 'accept', source: 'config' }) resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input)) } }, })
// Race 4: Bridge permission response from CCR (claude.ai) // When the bridge is connected, send the permission request to CCR and // subscribe for a response. Whichever side (CLI or CCR) responds first // wins via claim(). // // All tools are forwarded — CCR's generic allow/deny modal handles any // tool, and can return `updatedInput` when it has a dedicated renderer // (e.g. plan edit). Tools whose local dialog injects fields (ReviewArtifact // `selected`, AskUserQuestion `answers`) tolerate the field being missing // so generic remote approval degrades gracefully instead of throwing. if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.sendRequest( bridgeRequestId, ctx.tool.name, displayInput, ctx.toolUseID, description, result.suggestions, result.blockedPath, )
const signal = ctx.toolUseContext.abortController.signal const unsubscribe = bridgeCallbacks.onResponse( bridgeRequestId, response => { if (!claim()) return // Local user/hook/classifier already responded signal.removeEventListener('abort', unsubscribe) clearClassifierChecking(ctx.toolUseID) clearClassifierIndicator() ctx.removeFromQueue() channelUnsubscribe?.()
if (response.behavior === 'allow') { if (response.updatedPermissions?.length) { void ctx.persistPermissions(response.updatedPermissions) } ctx.logDecision( { decision: 'accept', source: { type: 'user', permanent: !!response.updatedPermissions?.length, }, }, { permissionPromptStartTimeMs }, ) resolveOnce(ctx.buildAllow(response.updatedInput ?? displayInput)) } else { ctx.logDecision( { decision: 'reject', source: { type: 'user_reject', hasFeedback: !!response.message, }, }, { permissionPromptStartTimeMs }, ) resolveOnce(ctx.cancelAndAbort(response.message)) } }, )
signal.addEventListener('abort', unsubscribe, { once: true }) }
// Channel permission relay — races alongside the bridge block above. Send a // permission prompt to every active channel (Telegram, iMessage, etc.) via // its MCP send_message tool, then race the reply against local/bridge/hook/ // classifier. The inbound "yes abc123" is intercepted in the notification // handler (useManageMCPConnections.ts) BEFORE enqueue, so it never reaches // Claude as a conversation turn. // // Unlike the bridge block, this still guards on `requiresUserInteraction` — // channel replies are pure yes/no with no `updatedInput` path. In practice // the guard is dead code today: all three `requiresUserInteraction` tools // (ExitPlanMode, AskUserQuestion, ReviewArtifact) return `isEnabled()===false` // when channels are configured, so they never reach this handler. // // Fire-and-forget send: if callTool fails (channel down, tool missing), // the subscription never fires and another racer wins. Graceful degradation // — the local dialog is always there as the floor. if ( (feature('KAIROS') || feature('KAIROS_CHANNELS')) && channelCallbacks && !ctx.tool.requiresUserInteraction?.() ) { const channelRequestId = shortRequestId(ctx.toolUseID) const allowedChannels = getAllowedChannels() const channelClients = filterPermissionRelayClients( ctx.toolUseContext.getAppState().mcp.clients, name => findChannelEntry(name, allowedChannels) !== undefined, )
if (channelClients.length > 0) { // Outbound is structured too (Kenneth's symmetry ask) — server owns // message formatting for its platform (Telegram markdown, iMessage // rich text, Discord embed). CC sends the RAW parts; server composes. // The old callTool('send_message', {text,content,message}) triple-key // hack is gone — no more guessing which arg name each plugin takes. const params: ChannelPermissionRequestParams = { request_id: channelRequestId, tool_name: ctx.tool.name, description, input_preview: truncateForPreview(displayInput), }
for (const client of channelClients) { if (client.type !== 'connected') continue // refine for TS void client.client .notification({ method: CHANNEL_PERMISSION_REQUEST_METHOD, params, }) .catch(e => { logForDebugging( `Channel permission_request failed for ${client.name}: ${errorMessage(e)}`, { level: 'error' }, ) }) }
const channelSignal = ctx.toolUseContext.abortController.signal // Wrap so BOTH the map delete AND the abort-listener teardown happen // at every call site. The 6 channelUnsubscribe?.() sites after local/ // hook/classifier wins previously only deleted the map entry — the // dead closure stayed registered on the session-scoped abort signal // until the session ended. Not a functional bug (Map.delete is // idempotent), but it held the closure alive. const mapUnsub = channelCallbacks.onResponse( channelRequestId, response => { if (!claim()) return // Another racer won channelUnsubscribe?.() // both: map delete + listener remove clearClassifierChecking(ctx.toolUseID) clearClassifierIndicator() ctx.removeFromQueue() // Bridge is the other remote — tell it we're done. if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.cancelRequest(bridgeRequestId) }
if (response.behavior === 'allow') { ctx.logDecision( { decision: 'accept', source: { type: 'user', permanent: false }, }, { permissionPromptStartTimeMs }, ) resolveOnce(ctx.buildAllow(displayInput)) } else { ctx.logDecision( { decision: 'reject', source: { type: 'user_reject', hasFeedback: false }, }, { permissionPromptStartTimeMs }, ) resolveOnce( ctx.cancelAndAbort(`Denied via channel ${response.fromServer}`), ) } }, ) channelUnsubscribe = () => { mapUnsub() channelSignal.removeEventListener('abort', channelUnsubscribe!) }
channelSignal.addEventListener('abort', channelUnsubscribe, { once: true, }) } }
// Skip hooks if they were already awaited in the coordinator branch above if (!awaitAutomatedChecksBeforeDialog) { // Execute PermissionRequest hooks asynchronously // If hook returns a decision before user responds, apply it void (async () => { if (isResolved()) return const currentAppState = ctx.toolUseContext.getAppState() const hookDecision = await ctx.runHooks( currentAppState.toolPermissionContext.mode, result.suggestions, result.updatedInput, permissionPromptStartTimeMs, ) if (!hookDecision || !claim()) return if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.cancelRequest(bridgeRequestId) } channelUnsubscribe?.() ctx.removeFromQueue() resolveOnce(hookDecision) })() }
// Execute bash classifier check asynchronously (if applicable) if ( feature('BASH_CLASSIFIER') && result.pendingClassifierCheck && ctx.tool.name === BASH_TOOL_NAME && !awaitAutomatedChecksBeforeDialog ) { // UI indicator for "classifier running" — set here (not in // toolExecution.ts) so commands that auto-allow via prefix rules // don't flash the indicator for a split second before allow returns. setClassifierChecking(ctx.toolUseID) void executeAsyncClassifierCheck( result.pendingClassifierCheck, ctx.toolUseContext.abortController.signal, ctx.toolUseContext.options.isNonInteractiveSession, { shouldContinue: () => !isResolved() && !userInteracted, onComplete: () => { clearClassifierChecking(ctx.toolUseID) clearClassifierIndicator() }, onAllow: decisionReason => { if (!claim()) return if (bridgeCallbacks && bridgeRequestId) { bridgeCallbacks.cancelRequest(bridgeRequestId) } channelUnsubscribe?.() clearClassifierChecking(ctx.toolUseID)
const matchedRule = decisionReason.type === 'classifier' ? (decisionReason.reason.match( /^Allowed by prompt rule: "(.+)"$/, )?.[1] ?? decisionReason.reason) : undefined
// Show auto-approved transition with dimmed options if (feature('TRANSCRIPT_CLASSIFIER')) { ctx.updateQueueItem({ classifierCheckInProgress: false, classifierAutoApproved: true, classifierMatchedRule: matchedRule, }) }
if ( feature('TRANSCRIPT_CLASSIFIER') && decisionReason.type === 'classifier' ) { if (decisionReason.classifier === 'auto-mode') { setYoloClassifierApproval(ctx.toolUseID, decisionReason.reason) } else if (matchedRule) { setClassifierApproval(ctx.toolUseID, matchedRule) } }
ctx.logDecision( { decision: 'accept', source: { type: 'classifier' } }, { permissionPromptStartTimeMs }, ) resolveOnce(ctx.buildAllow(ctx.input, { decisionReason }))
// Keep checkmark visible, then remove dialog. // 3s if terminal is focused (user can see it), 1s if not. // User can dismiss early with Esc via onDismissCheckmark. const signal = ctx.toolUseContext.abortController.signal checkmarkAbortHandler = () => { if (checkmarkTransitionTimer) { clearTimeout(checkmarkTransitionTimer) checkmarkTransitionTimer = undefined // Sibling Bash error can fire this (StreamingToolExecutor // cascades via siblingAbortController) — must drop the // cosmetic ✓ dialog or it blocks the next queued item. ctx.removeFromQueue() } } const checkmarkMs = getTerminalFocused() ? 3000 : 1000 checkmarkTransitionTimer = setTimeout(() => { checkmarkTransitionTimer = undefined if (checkmarkAbortHandler) { signal.removeEventListener('abort', checkmarkAbortHandler) checkmarkAbortHandler = undefined } ctx.removeFromQueue() }, checkmarkMs) signal.addEventListener('abort', checkmarkAbortHandler, { once: true, }) }, }, ).catch(error => { // Log classifier API errors for debugging but don't propagate them as interruptions // These errors can be network failures, rate limits, or model issues - not user cancellations logForDebugging(`Async classifier check failed: ${errorMessage(error)}`, { level: 'error', }) }) }}
// --
export { handleInteractivePermission }export type { InteractivePermissionParams }