/ utils / permissions / permissionExplainer.ts
permissionExplainer.ts
  1  import { z } from 'zod/v4'
  2  import { logEvent } from '../../services/analytics/index.js'
  3  import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
  4  import type { AssistantMessage, Message } from '../../types/message.js'
  5  import { getGlobalConfig } from '../config.js'
  6  import { logForDebugging } from '../debug.js'
  7  import { errorMessage } from '../errors.js'
  8  import { lazySchema } from '../lazySchema.js'
  9  import { logError } from '../log.js'
 10  import { getMainLoopModel } from '../model/model.js'
 11  import { sideQuery } from '../sideQuery.js'
 12  import { jsonStringify } from '../slowOperations.js'
 13  
 14  export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH'
 15  
 16  // Map risk levels to numeric values for analytics
 17  const RISK_LEVEL_NUMERIC: Record<RiskLevel, number> = {
 18    LOW: 1,
 19    MEDIUM: 2,
 20    HIGH: 3,
 21  }
 22  
 23  // Error type codes for analytics
 24  const ERROR_TYPE_PARSE = 1
 25  const ERROR_TYPE_NETWORK = 2
 26  const ERROR_TYPE_UNKNOWN = 3
 27  
 28  export type PermissionExplanation = {
 29    riskLevel: RiskLevel
 30    explanation: string
 31    reasoning: string
 32    risk: string
 33  }
 34  
 35  type GenerateExplanationParams = {
 36    toolName: string
 37    toolInput: unknown
 38    toolDescription?: string
 39    messages?: Message[]
 40    signal: AbortSignal
 41  }
 42  
 43  const SYSTEM_PROMPT = `Analyze shell commands and explain what they do, why you're running them, and potential risks.`
 44  
 45  // Tool definition for forced structured output (no beta required)
 46  const EXPLAIN_COMMAND_TOOL = {
 47    name: 'explain_command',
 48    description: 'Provide an explanation of a shell command',
 49    input_schema: {
 50      type: 'object' as const,
 51      properties: {
 52        explanation: {
 53          type: 'string',
 54          description: 'What this command does (1-2 sentences)',
 55        },
 56        reasoning: {
 57          type: 'string',
 58          description:
 59            'Why YOU are running this command. Start with "I" - e.g. "I need to check the file contents"',
 60        },
 61        risk: {
 62          type: 'string',
 63          description: 'What could go wrong, under 15 words',
 64        },
 65        riskLevel: {
 66          type: 'string',
 67          enum: ['LOW', 'MEDIUM', 'HIGH'],
 68          description:
 69            'LOW (safe dev workflows), MEDIUM (recoverable changes), HIGH (dangerous/irreversible)',
 70        },
 71      },
 72      required: ['explanation', 'reasoning', 'risk', 'riskLevel'],
 73    },
 74  }
 75  
 76  // Zod schema for parsing and validating the response
 77  const RiskAssessmentSchema = lazySchema(() =>
 78    z.object({
 79      riskLevel: z.enum(['LOW', 'MEDIUM', 'HIGH']),
 80      explanation: z.string(),
 81      reasoning: z.string(),
 82      risk: z.string(),
 83    }),
 84  )
 85  
 86  function formatToolInput(input: unknown): string {
 87    if (typeof input === 'string') {
 88      return input
 89    }
 90    try {
 91      return jsonStringify(input, null, 2)
 92    } catch {
 93      return String(input)
 94    }
 95  }
 96  
 97  /**
 98   * Extract recent conversation context from messages for the explainer.
 99   * Returns a summary of recent assistant messages to provide context
100   * for "why" this command is being run.
101   */
102  function extractConversationContext(
103    messages: Message[],
104    maxChars = 1000,
105  ): string {
106    // Get recent assistant messages (they contain Claude's reasoning)
107    const assistantMessages = messages
108      .filter((m): m is AssistantMessage => m.type === 'assistant')
109      .slice(-3) // Last 3 assistant messages
110  
111    const contextParts: string[] = []
112    let totalChars = 0
113  
114    for (const msg of assistantMessages.reverse()) {
115      // Extract text content from assistant message
116      const textBlocks = msg.message.content
117        .filter(c => c.type === 'text')
118        .map(c => ('text' in c ? c.text : ''))
119        .join(' ')
120  
121      if (textBlocks && totalChars < maxChars) {
122        const remaining = maxChars - totalChars
123        const truncated =
124          textBlocks.length > remaining
125            ? textBlocks.slice(0, remaining) + '...'
126            : textBlocks
127        contextParts.unshift(truncated)
128        totalChars += truncated.length
129      }
130    }
131  
132    return contextParts.join('\n\n')
133  }
134  
135  /**
136   * Check if the permission explainer feature is enabled.
137   * Enabled by default; users can opt out via config.
138   */
139  export function isPermissionExplainerEnabled(): boolean {
140    return getGlobalConfig().permissionExplainerEnabled !== false
141  }
142  
143  /**
144   * Generate a permission explanation using Haiku with structured output.
145   * Returns null if the feature is disabled, request is aborted, or an error occurs.
146   */
147  export async function generatePermissionExplanation({
148    toolName,
149    toolInput,
150    toolDescription,
151    messages,
152    signal,
153  }: GenerateExplanationParams): Promise<PermissionExplanation | null> {
154    // Check if feature is enabled
155    if (!isPermissionExplainerEnabled()) {
156      return null
157    }
158  
159    const startTime = Date.now()
160  
161    try {
162      const formattedInput = formatToolInput(toolInput)
163      const conversationContext = messages?.length
164        ? extractConversationContext(messages)
165        : ''
166  
167      const userPrompt = `Tool: ${toolName}
168  ${toolDescription ? `Description: ${toolDescription}\n` : ''}
169  Input:
170  ${formattedInput}
171  ${conversationContext ? `\nRecent conversation context:\n${conversationContext}` : ''}
172  
173  Explain this command in context.`
174  
175      const model = getMainLoopModel()
176  
177      // Use sideQuery with forced tool choice for guaranteed structured output
178      const response = await sideQuery({
179        model,
180        system: SYSTEM_PROMPT,
181        messages: [{ role: 'user', content: userPrompt }],
182        tools: [EXPLAIN_COMMAND_TOOL],
183        tool_choice: { type: 'tool', name: 'explain_command' },
184        signal,
185        querySource: 'permission_explainer',
186      })
187  
188      const latencyMs = Date.now() - startTime
189      logForDebugging(
190        `Permission explainer: API returned in ${latencyMs}ms, stop_reason=${response.stop_reason}`,
191      )
192  
193      // Extract structured data from tool use block
194      const toolUseBlock = response.content.find(c => c.type === 'tool_use')
195      if (toolUseBlock && toolUseBlock.type === 'tool_use') {
196        logForDebugging(
197          `Permission explainer: tool input: ${jsonStringify(toolUseBlock.input).slice(0, 500)}`,
198        )
199        const result = RiskAssessmentSchema().safeParse(toolUseBlock.input)
200  
201        if (result.success) {
202          const explanation: PermissionExplanation = {
203            riskLevel: result.data.riskLevel,
204            explanation: result.data.explanation,
205            reasoning: result.data.reasoning,
206            risk: result.data.risk,
207          }
208  
209          logEvent('tengu_permission_explainer_generated', {
210            tool_name: sanitizeToolNameForAnalytics(toolName),
211            risk_level: RISK_LEVEL_NUMERIC[explanation.riskLevel],
212            latency_ms: latencyMs,
213          })
214          logForDebugging(
215            `Permission explainer: ${explanation.riskLevel} risk for ${toolName} (${latencyMs}ms)`,
216          )
217          return explanation
218        }
219      }
220  
221      // No valid JSON in response
222      logEvent('tengu_permission_explainer_error', {
223        tool_name: sanitizeToolNameForAnalytics(toolName),
224        error_type: ERROR_TYPE_PARSE,
225        latency_ms: latencyMs,
226      })
227      logForDebugging(`Permission explainer: no parsed output in response`)
228      return null
229    } catch (error) {
230      const latencyMs = Date.now() - startTime
231  
232      // Don't log aborted requests as errors
233      if (signal.aborted) {
234        logForDebugging(`Permission explainer: request aborted for ${toolName}`)
235        return null
236      }
237  
238      logForDebugging(`Permission explainer error: ${errorMessage(error)}`)
239      logError(error)
240      logEvent('tengu_permission_explainer_error', {
241        tool_name: sanitizeToolNameForAnalytics(toolName),
242        error_type:
243          error instanceof Error && error.name === 'AbortError'
244            ? ERROR_TYPE_NETWORK
245            : ERROR_TYPE_UNKNOWN,
246        latency_ms: latencyMs,
247      })
248      return null
249    }
250  }