source dump of claude code
at main 388 lines 13 kB view raw
1import { feature } from 'bun:bundle' 2import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 3import { 4 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5 logEvent, 6} from 'src/services/analytics/index.js' 7import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' 8import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js' 9import type { 10 ToolPermissionContext, 11 Tool as ToolType, 12 ToolUseContext, 13} from '../../Tool.js' 14import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js' 15import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 16import type { AssistantMessage } from '../../types/message.js' 17import type { 18 PendingClassifierCheck, 19 PermissionAllowDecision, 20 PermissionDecisionReason, 21 PermissionDenyDecision, 22} from '../../types/permissions.js' 23import { setClassifierApproval } from '../../utils/classifierApprovals.js' 24import { logForDebugging } from '../../utils/debug.js' 25import { executePermissionRequestHooks } from '../../utils/hooks.js' 26import { 27 REJECT_MESSAGE, 28 REJECT_MESSAGE_WITH_REASON_PREFIX, 29 SUBAGENT_REJECT_MESSAGE, 30 SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX, 31 withMemoryCorrectionHint, 32} from '../../utils/messages.js' 33import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 34import { 35 applyPermissionUpdates, 36 persistPermissionUpdates, 37 supportsPersistence, 38} from '../../utils/permissions/PermissionUpdate.js' 39import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 40import { 41 logPermissionDecision, 42 type PermissionDecisionArgs, 43} from './permissionLogging.js' 44 45type PermissionApprovalSource = 46 | { type: 'hook'; permanent?: boolean } 47 | { type: 'user'; permanent: boolean } 48 | { type: 'classifier' } 49 50type PermissionRejectionSource = 51 | { type: 'hook' } 52 | { type: 'user_abort' } 53 | { type: 'user_reject'; hasFeedback: boolean } 54 55// Generic interface for permission queue operations, decoupled from React. 56// In the REPL, these are backed by React state. 57type PermissionQueueOps = { 58 push(item: ToolUseConfirm): void 59 remove(toolUseID: string): void 60 update(toolUseID: string, patch: Partial<ToolUseConfirm>): void 61} 62 63type ResolveOnce<T> = { 64 resolve(value: T): void 65 isResolved(): boolean 66 /** 67 * Atomically check-and-mark as resolved. Returns true if this caller 68 * won the race (nobody else has resolved yet), false otherwise. 69 * Use this in async callbacks BEFORE awaiting, to close the window 70 * between the `isResolved()` check and the actual `resolve()` call. 71 */ 72 claim(): boolean 73} 74 75function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> { 76 let claimed = false 77 let delivered = false 78 return { 79 resolve(value: T) { 80 if (delivered) return 81 delivered = true 82 claimed = true 83 resolve(value) 84 }, 85 isResolved() { 86 return claimed 87 }, 88 claim() { 89 if (claimed) return false 90 claimed = true 91 return true 92 }, 93 } 94} 95 96function createPermissionContext( 97 tool: ToolType, 98 input: Record<string, unknown>, 99 toolUseContext: ToolUseContext, 100 assistantMessage: AssistantMessage, 101 toolUseID: string, 102 setToolPermissionContext: (context: ToolPermissionContext) => void, 103 queueOps?: PermissionQueueOps, 104) { 105 const messageId = assistantMessage.message.id 106 const ctx = { 107 tool, 108 input, 109 toolUseContext, 110 assistantMessage, 111 messageId, 112 toolUseID, 113 logDecision( 114 args: PermissionDecisionArgs, 115 opts?: { 116 input?: Record<string, unknown> 117 permissionPromptStartTimeMs?: number 118 }, 119 ) { 120 logPermissionDecision( 121 { 122 tool, 123 input: opts?.input ?? input, 124 toolUseContext, 125 messageId, 126 toolUseID, 127 }, 128 args, 129 opts?.permissionPromptStartTimeMs, 130 ) 131 }, 132 logCancelled() { 133 logEvent('tengu_tool_use_cancelled', { 134 messageID: 135 messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 136 toolName: sanitizeToolNameForAnalytics(tool.name), 137 }) 138 }, 139 async persistPermissions(updates: PermissionUpdate[]) { 140 if (updates.length === 0) return false 141 persistPermissionUpdates(updates) 142 const appState = toolUseContext.getAppState() 143 setToolPermissionContext( 144 applyPermissionUpdates(appState.toolPermissionContext, updates), 145 ) 146 return updates.some(update => supportsPersistence(update.destination)) 147 }, 148 resolveIfAborted(resolve: (decision: PermissionDecision) => void) { 149 if (!toolUseContext.abortController.signal.aborted) return false 150 this.logCancelled() 151 resolve(this.cancelAndAbort(undefined, true)) 152 return true 153 }, 154 cancelAndAbort( 155 feedback?: string, 156 isAbort?: boolean, 157 contentBlocks?: ContentBlockParam[], 158 ): PermissionDecision { 159 const sub = !!toolUseContext.agentId 160 const baseMessage = feedback 161 ? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}` 162 : sub 163 ? SUBAGENT_REJECT_MESSAGE 164 : REJECT_MESSAGE 165 const message = sub ? baseMessage : withMemoryCorrectionHint(baseMessage) 166 if (isAbort || (!feedback && !contentBlocks?.length && !sub)) { 167 logForDebugging( 168 `Aborting: tool=${tool.name} isAbort=${isAbort} hasFeedback=${!!feedback} isSubagent=${sub}`, 169 ) 170 toolUseContext.abortController.abort() 171 } 172 return { behavior: 'ask', message, contentBlocks } 173 }, 174 ...(feature('BASH_CLASSIFIER') 175 ? { 176 async tryClassifier( 177 pendingClassifierCheck: PendingClassifierCheck | undefined, 178 updatedInput: Record<string, unknown> | undefined, 179 ): Promise<PermissionDecision | null> { 180 if (tool.name !== BASH_TOOL_NAME || !pendingClassifierCheck) { 181 return null 182 } 183 const classifierDecision = await awaitClassifierAutoApproval( 184 pendingClassifierCheck, 185 toolUseContext.abortController.signal, 186 toolUseContext.options.isNonInteractiveSession, 187 ) 188 if (!classifierDecision) { 189 return null 190 } 191 if ( 192 feature('TRANSCRIPT_CLASSIFIER') && 193 classifierDecision.type === 'classifier' 194 ) { 195 const matchedRule = classifierDecision.reason.match( 196 /^Allowed by prompt rule: "(.+)"$/, 197 )?.[1] 198 if (matchedRule) { 199 setClassifierApproval(toolUseID, matchedRule) 200 } 201 } 202 logPermissionDecision( 203 { tool, input, toolUseContext, messageId, toolUseID }, 204 { decision: 'accept', source: { type: 'classifier' } }, 205 undefined, 206 ) 207 return { 208 behavior: 'allow' as const, 209 updatedInput: updatedInput ?? input, 210 userModified: false, 211 decisionReason: classifierDecision, 212 } 213 }, 214 } 215 : {}), 216 async runHooks( 217 permissionMode: string | undefined, 218 suggestions: PermissionUpdate[] | undefined, 219 updatedInput?: Record<string, unknown>, 220 permissionPromptStartTimeMs?: number, 221 ): Promise<PermissionDecision | null> { 222 for await (const hookResult of executePermissionRequestHooks( 223 tool.name, 224 toolUseID, 225 input, 226 toolUseContext, 227 permissionMode, 228 suggestions, 229 toolUseContext.abortController.signal, 230 )) { 231 if (hookResult.permissionRequestResult) { 232 const decision = hookResult.permissionRequestResult 233 if (decision.behavior === 'allow') { 234 const finalInput = decision.updatedInput ?? updatedInput ?? input 235 return await this.handleHookAllow( 236 finalInput, 237 decision.updatedPermissions ?? [], 238 permissionPromptStartTimeMs, 239 ) 240 } else if (decision.behavior === 'deny') { 241 this.logDecision( 242 { decision: 'reject', source: { type: 'hook' } }, 243 { permissionPromptStartTimeMs }, 244 ) 245 if (decision.interrupt) { 246 logForDebugging( 247 `Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`, 248 ) 249 toolUseContext.abortController.abort() 250 } 251 return this.buildDeny( 252 decision.message || 'Permission denied by hook', 253 { 254 type: 'hook', 255 hookName: 'PermissionRequest', 256 reason: decision.message, 257 }, 258 ) 259 } 260 } 261 } 262 return null 263 }, 264 buildAllow( 265 updatedInput: Record<string, unknown>, 266 opts?: { 267 userModified?: boolean 268 decisionReason?: PermissionDecisionReason 269 acceptFeedback?: string 270 contentBlocks?: ContentBlockParam[] 271 }, 272 ): PermissionAllowDecision { 273 return { 274 behavior: 'allow' as const, 275 updatedInput, 276 userModified: opts?.userModified ?? false, 277 ...(opts?.decisionReason && { decisionReason: opts.decisionReason }), 278 ...(opts?.acceptFeedback && { acceptFeedback: opts.acceptFeedback }), 279 ...(opts?.contentBlocks && 280 opts.contentBlocks.length > 0 && { 281 contentBlocks: opts.contentBlocks, 282 }), 283 } 284 }, 285 buildDeny( 286 message: string, 287 decisionReason: PermissionDecisionReason, 288 ): PermissionDenyDecision { 289 return { behavior: 'deny' as const, message, decisionReason } 290 }, 291 async handleUserAllow( 292 updatedInput: Record<string, unknown>, 293 permissionUpdates: PermissionUpdate[], 294 feedback?: string, 295 permissionPromptStartTimeMs?: number, 296 contentBlocks?: ContentBlockParam[], 297 decisionReason?: PermissionDecisionReason, 298 ): Promise<PermissionAllowDecision> { 299 const acceptedPermanentUpdates = 300 await this.persistPermissions(permissionUpdates) 301 this.logDecision( 302 { 303 decision: 'accept', 304 source: { type: 'user', permanent: acceptedPermanentUpdates }, 305 }, 306 { input: updatedInput, permissionPromptStartTimeMs }, 307 ) 308 const userModified = tool.inputsEquivalent 309 ? !tool.inputsEquivalent(input, updatedInput) 310 : false 311 const trimmedFeedback = feedback?.trim() 312 return this.buildAllow(updatedInput, { 313 userModified, 314 decisionReason, 315 acceptFeedback: trimmedFeedback || undefined, 316 contentBlocks, 317 }) 318 }, 319 async handleHookAllow( 320 finalInput: Record<string, unknown>, 321 permissionUpdates: PermissionUpdate[], 322 permissionPromptStartTimeMs?: number, 323 ): Promise<PermissionAllowDecision> { 324 const acceptedPermanentUpdates = 325 await this.persistPermissions(permissionUpdates) 326 this.logDecision( 327 { 328 decision: 'accept', 329 source: { type: 'hook', permanent: acceptedPermanentUpdates }, 330 }, 331 { input: finalInput, permissionPromptStartTimeMs }, 332 ) 333 return this.buildAllow(finalInput, { 334 decisionReason: { type: 'hook', hookName: 'PermissionRequest' }, 335 }) 336 }, 337 pushToQueue(item: ToolUseConfirm) { 338 queueOps?.push(item) 339 }, 340 removeFromQueue() { 341 queueOps?.remove(toolUseID) 342 }, 343 updateQueueItem(patch: Partial<ToolUseConfirm>) { 344 queueOps?.update(toolUseID, patch) 345 }, 346 } 347 return Object.freeze(ctx) 348} 349 350type PermissionContext = ReturnType<typeof createPermissionContext> 351 352/** 353 * Create a PermissionQueueOps backed by a React state setter. 354 * This is the bridge between React's `setToolUseConfirmQueue` and the 355 * generic queue interface used by PermissionContext. 356 */ 357function createPermissionQueueOps( 358 setToolUseConfirmQueue: React.Dispatch< 359 React.SetStateAction<ToolUseConfirm[]> 360 >, 361): PermissionQueueOps { 362 return { 363 push(item: ToolUseConfirm) { 364 setToolUseConfirmQueue(queue => [...queue, item]) 365 }, 366 remove(toolUseID: string) { 367 setToolUseConfirmQueue(queue => 368 queue.filter(item => item.toolUseID !== toolUseID), 369 ) 370 }, 371 update(toolUseID: string, patch: Partial<ToolUseConfirm>) { 372 setToolUseConfirmQueue(queue => 373 queue.map(item => 374 item.toolUseID === toolUseID ? { ...item, ...patch } : item, 375 ), 376 ) 377 }, 378 } 379} 380 381export { createPermissionContext, createPermissionQueueOps, createResolveOnce } 382export type { 383 PermissionContext, 384 PermissionApprovalSource, 385 PermissionQueueOps, 386 PermissionRejectionSource, 387 ResolveOnce, 388}