/ components / permissions / hooks.ts
hooks.ts
  1  import { feature } from 'bun:bundle'
  2  import { useEffect, useRef } from 'react'
  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 { BashTool } from 'src/tools/BashTool/BashTool.js'
  9  import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
 10  import type {
 11    PermissionDecisionReason,
 12    PermissionResult,
 13  } from 'src/utils/permissions/PermissionResult.js'
 14  import {
 15    extractRules,
 16    hasRules,
 17  } from 'src/utils/permissions/PermissionUpdate.js'
 18  import { permissionRuleValueToString } from 'src/utils/permissions/permissionRuleParser.js'
 19  import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
 20  import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
 21  import { useSetAppState } from '../../state/AppState.js'
 22  import { env } from '../../utils/env.js'
 23  import { jsonStringify } from '../../utils/slowOperations.js'
 24  import { type CompletionType, logUnaryEvent } from '../../utils/unaryLogging.js'
 25  
 26  export type UnaryEvent = {
 27    completion_type: CompletionType
 28    language_name: string | Promise<string>
 29  }
 30  
 31  function permissionResultToLog(permissionResult: PermissionResult): string {
 32    switch (permissionResult.behavior) {
 33      case 'allow':
 34        return 'allow'
 35      case 'ask': {
 36        const rules = extractRules(permissionResult.suggestions)
 37        const suggestions =
 38          rules.length > 0
 39            ? rules.map(r => permissionRuleValueToString(r)).join(', ')
 40            : 'none'
 41        return `ask: ${permissionResult.message}, 
 42  suggestions: ${suggestions}
 43  reason: ${decisionReasonToString(permissionResult.decisionReason)}`
 44      }
 45      case 'deny':
 46        return `deny: ${permissionResult.message},
 47  reason: ${decisionReasonToString(permissionResult.decisionReason)}`
 48      case 'passthrough': {
 49        const rules = extractRules(permissionResult.suggestions)
 50        const suggestions =
 51          rules.length > 0
 52            ? rules.map(r => permissionRuleValueToString(r)).join(', ')
 53            : 'none'
 54        return `passthrough: ${permissionResult.message},
 55  suggestions: ${suggestions}
 56  reason: ${decisionReasonToString(permissionResult.decisionReason)}`
 57      }
 58    }
 59  }
 60  
 61  function decisionReasonToString(
 62    decisionReason: PermissionDecisionReason | undefined,
 63  ): string {
 64    if (!decisionReason) {
 65      return 'No decision reason'
 66    }
 67    if (
 68      (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
 69      decisionReason.type === 'classifier'
 70    ) {
 71      return `Classifier: ${decisionReason.classifier}, Reason: ${decisionReason.reason}`
 72    }
 73    switch (decisionReason.type) {
 74      case 'rule':
 75        return `Rule: ${permissionRuleValueToString(decisionReason.rule.ruleValue)}`
 76      case 'mode':
 77        return `Mode: ${decisionReason.mode}`
 78      case 'subcommandResults':
 79        return `Subcommand Results: ${Array.from(decisionReason.reasons.entries())
 80          .map(([key, value]) => `${key}: ${permissionResultToLog(value)}`)
 81          .join(', \n')}`
 82      case 'permissionPromptTool':
 83        return `Permission Tool: ${decisionReason.permissionPromptToolName}, Result: ${jsonStringify(decisionReason.toolResult)}`
 84      case 'hook':
 85        return `Hook: ${decisionReason.hookName}${decisionReason.reason ? `, Reason: ${decisionReason.reason}` : ''}`
 86      case 'workingDir':
 87        return `Working Directory: ${decisionReason.reason}`
 88      case 'safetyCheck':
 89        return `Safety check: ${decisionReason.reason}`
 90      case 'other':
 91        return `Other: ${decisionReason.reason}`
 92      default:
 93        return jsonStringify(decisionReason, null, 2)
 94    }
 95  }
 96  
 97  /**
 98   * Logs permission request events using analytics and unary logging.
 99   * Handles both the analytics event and the unary event logging.
100   */
101  export function usePermissionRequestLogging(
102    toolUseConfirm: ToolUseConfirm,
103    unaryEvent: UnaryEvent,
104  ): void {
105    const setAppState = useSetAppState()
106    // Guard against effect re-firing if toolUseConfirm's object reference
107    // changes during a single dialog's lifetime (e.g., parent re-renders with a
108    // fresh object). Without this, the unconditional setAppState below can
109    // cascade into an infinite microtask loop — each re-fire does another
110    // setAppState spread + (ant builds) splitCommand → shell-quote regex,
111    // pegging CPU at 100% and leaking ~500MB/min in JSRopeString/RegExp allocs.
112    // The component is keyed by toolUseID, so this ref resets on remount —
113    // we only need to dedupe re-fires WITHIN one dialog instance.
114    const loggedToolUseID = useRef<string | null>(null)
115  
116    useEffect(() => {
117      if (loggedToolUseID.current === toolUseConfirm.toolUseID) {
118        return
119      }
120      loggedToolUseID.current = toolUseConfirm.toolUseID
121  
122      // Increment permission prompt count for attribution tracking
123      setAppState(prev => ({
124        ...prev,
125        attribution: {
126          ...prev.attribution,
127          permissionPromptCount: prev.attribution.permissionPromptCount + 1,
128        },
129      }))
130  
131      // Log analytics event
132      logEvent('tengu_tool_use_show_permission_request', {
133        messageID: toolUseConfirm.assistantMessage.message
134          .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
135        toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
136        isMcp: toolUseConfirm.tool.isMcp ?? false,
137        decisionReasonType: toolUseConfirm.permissionResult.decisionReason
138          ?.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
139        sandboxEnabled: SandboxManager.isSandboxingEnabled(),
140      })
141  
142      if (process.env.USER_TYPE === 'ant') {
143        const permissionResult = toolUseConfirm.permissionResult
144        if (
145          toolUseConfirm.tool.name === BashTool.name &&
146          permissionResult.behavior === 'ask' &&
147          !hasRules(permissionResult.suggestions)
148        ) {
149          // Log if no rule suggestions ("always allow") are provided
150          logEvent('tengu_internal_tool_use_permission_request_no_always_allow', {
151            messageID: toolUseConfirm.assistantMessage.message
152              .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
153            toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
154            isMcp: toolUseConfirm.tool.isMcp ?? false,
155            decisionReasonType: (permissionResult.decisionReason?.type ??
156              'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
157            sandboxEnabled: SandboxManager.isSandboxingEnabled(),
158  
159            // This DOES contain code/filepaths and should not be logged in the public build!
160            decisionReasonDetails: decisionReasonToString(
161              permissionResult.decisionReason,
162            ) as never,
163          })
164        }
165      }
166  
167      // [ANT-ONLY] Log bash tool calls, so we can categorize
168      // & burn down calls that should have been allowed
169      if (process.env.USER_TYPE === 'ant') {
170        const parsedInput = BashTool.inputSchema.safeParse(toolUseConfirm.input)
171        if (
172          toolUseConfirm.tool.name === BashTool.name &&
173          toolUseConfirm.permissionResult.behavior === 'ask' &&
174          parsedInput.success
175        ) {
176          // Note: All metadata fields in this event contain code/filepaths
177          let split = [parsedInput.data.command]
178          try {
179            split = splitCommand_DEPRECATED(parsedInput.data.command)
180          } catch {
181            // Ignore parse errors here - just log the full command
182          }
183          logEvent('tengu_internal_bash_tool_use_permission_request', {
184            parts: jsonStringify(
185              split,
186            ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
187            input: jsonStringify(
188              toolUseConfirm.input,
189            ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
190            decisionReasonType: toolUseConfirm.permissionResult.decisionReason
191              ?.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
192            decisionReason: decisionReasonToString(
193              toolUseConfirm.permissionResult.decisionReason,
194            ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
195          })
196        }
197      }
198  
199      void logUnaryEvent({
200        completion_type: unaryEvent.completion_type,
201        event: 'response',
202        metadata: {
203          language_name: unaryEvent.language_name,
204          message_id: toolUseConfirm.assistantMessage.message.id,
205          platform: env.platform,
206        },
207      })
208    }, [toolUseConfirm, unaryEvent, setAppState])
209  }