/ hooks / toolPermission / PermissionContext.ts
PermissionContext.ts
  1  import { feature } from 'bun:bundle'
  2  import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
  3  import {
  4    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  5    logEvent,
  6  } from 'src/services/analytics/index.js'
  7  import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
  8  import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
  9  import type {
 10    ToolPermissionContext,
 11    Tool as ToolType,
 12    ToolUseContext,
 13  } from '../../Tool.js'
 14  import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js'
 15  import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
 16  import type { AssistantMessage } from '../../types/message.js'
 17  import type {
 18    PendingClassifierCheck,
 19    PermissionAllowDecision,
 20    PermissionDecisionReason,
 21    PermissionDenyDecision,
 22  } from '../../types/permissions.js'
 23  import { setClassifierApproval } from '../../utils/classifierApprovals.js'
 24  import { logForDebugging } from '../../utils/debug.js'
 25  import { executePermissionRequestHooks } from '../../utils/hooks.js'
 26  import {
 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'
 33  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
 34  import {
 35    applyPermissionUpdates,
 36    persistPermissionUpdates,
 37    supportsPersistence,
 38  } from '../../utils/permissions/PermissionUpdate.js'
 39  import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
 40  import {
 41    logPermissionDecision,
 42    type PermissionDecisionArgs,
 43  } from './permissionLogging.js'
 44  
 45  type PermissionApprovalSource =
 46    | { type: 'hook'; permanent?: boolean }
 47    | { type: 'user'; permanent: boolean }
 48    | { type: 'classifier' }
 49  
 50  type 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.
 57  type PermissionQueueOps = {
 58    push(item: ToolUseConfirm): void
 59    remove(toolUseID: string): void
 60    update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
 61  }
 62  
 63  type 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  
 75  function 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  
 96  function 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  
350  type 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   */
357  function 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  
381  export { createPermissionContext, createPermissionQueueOps, createResolveOnce }
382  export type {
383    PermissionContext,
384    PermissionApprovalSource,
385    PermissionQueueOps,
386    PermissionRejectionSource,
387    ResolveOnce,
388  }