source dump of claude code
at main 250 lines 7.6 kB view raw
1import { z } from 'zod/v4' 2import { logEvent } from '../../services/analytics/index.js' 3import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' 4import type { AssistantMessage, Message } from '../../types/message.js' 5import { getGlobalConfig } from '../config.js' 6import { logForDebugging } from '../debug.js' 7import { errorMessage } from '../errors.js' 8import { lazySchema } from '../lazySchema.js' 9import { logError } from '../log.js' 10import { getMainLoopModel } from '../model/model.js' 11import { sideQuery } from '../sideQuery.js' 12import { jsonStringify } from '../slowOperations.js' 13 14export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' 15 16// Map risk levels to numeric values for analytics 17const RISK_LEVEL_NUMERIC: Record<RiskLevel, number> = { 18 LOW: 1, 19 MEDIUM: 2, 20 HIGH: 3, 21} 22 23// Error type codes for analytics 24const ERROR_TYPE_PARSE = 1 25const ERROR_TYPE_NETWORK = 2 26const ERROR_TYPE_UNKNOWN = 3 27 28export type PermissionExplanation = { 29 riskLevel: RiskLevel 30 explanation: string 31 reasoning: string 32 risk: string 33} 34 35type GenerateExplanationParams = { 36 toolName: string 37 toolInput: unknown 38 toolDescription?: string 39 messages?: Message[] 40 signal: AbortSignal 41} 42 43const 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) 46const 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 77const 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 86function 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 */ 102function 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 */ 139export 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 */ 147export 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` : ''} 169Input: 170${formattedInput} 171${conversationContext ? `\nRecent conversation context:\n${conversationContext}` : ''} 172 173Explain 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}