chore: initialize recovered claude workspace
This commit is contained in:
250
src/utils/permissions/permissionExplainer.ts
Normal file
250
src/utils/permissions/permissionExplainer.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
|
||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||
import { getGlobalConfig } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { logError } from '../log.js'
|
||||
import { getMainLoopModel } from '../model/model.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
|
||||
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
|
||||
// Map risk levels to numeric values for analytics
|
||||
const RISK_LEVEL_NUMERIC: Record<RiskLevel, number> = {
|
||||
LOW: 1,
|
||||
MEDIUM: 2,
|
||||
HIGH: 3,
|
||||
}
|
||||
|
||||
// Error type codes for analytics
|
||||
const ERROR_TYPE_PARSE = 1
|
||||
const ERROR_TYPE_NETWORK = 2
|
||||
const ERROR_TYPE_UNKNOWN = 3
|
||||
|
||||
export type PermissionExplanation = {
|
||||
riskLevel: RiskLevel
|
||||
explanation: string
|
||||
reasoning: string
|
||||
risk: string
|
||||
}
|
||||
|
||||
type GenerateExplanationParams = {
|
||||
toolName: string
|
||||
toolInput: unknown
|
||||
toolDescription?: string
|
||||
messages?: Message[]
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Analyze shell commands and explain what they do, why you're running them, and potential risks.`
|
||||
|
||||
// Tool definition for forced structured output (no beta required)
|
||||
const EXPLAIN_COMMAND_TOOL = {
|
||||
name: 'explain_command',
|
||||
description: 'Provide an explanation of a shell command',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
explanation: {
|
||||
type: 'string',
|
||||
description: 'What this command does (1-2 sentences)',
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Why YOU are running this command. Start with "I" - e.g. "I need to check the file contents"',
|
||||
},
|
||||
risk: {
|
||||
type: 'string',
|
||||
description: 'What could go wrong, under 15 words',
|
||||
},
|
||||
riskLevel: {
|
||||
type: 'string',
|
||||
enum: ['LOW', 'MEDIUM', 'HIGH'],
|
||||
description:
|
||||
'LOW (safe dev workflows), MEDIUM (recoverable changes), HIGH (dangerous/irreversible)',
|
||||
},
|
||||
},
|
||||
required: ['explanation', 'reasoning', 'risk', 'riskLevel'],
|
||||
},
|
||||
}
|
||||
|
||||
// Zod schema for parsing and validating the response
|
||||
const RiskAssessmentSchema = lazySchema(() =>
|
||||
z.object({
|
||||
riskLevel: z.enum(['LOW', 'MEDIUM', 'HIGH']),
|
||||
explanation: z.string(),
|
||||
reasoning: z.string(),
|
||||
risk: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
function formatToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
}
|
||||
try {
|
||||
return jsonStringify(input, null, 2)
|
||||
} catch {
|
||||
return String(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recent conversation context from messages for the explainer.
|
||||
* Returns a summary of recent assistant messages to provide context
|
||||
* for "why" this command is being run.
|
||||
*/
|
||||
function extractConversationContext(
|
||||
messages: Message[],
|
||||
maxChars = 1000,
|
||||
): string {
|
||||
// Get recent assistant messages (they contain Claude's reasoning)
|
||||
const assistantMessages = messages
|
||||
.filter((m): m is AssistantMessage => m.type === 'assistant')
|
||||
.slice(-3) // Last 3 assistant messages
|
||||
|
||||
const contextParts: string[] = []
|
||||
let totalChars = 0
|
||||
|
||||
for (const msg of assistantMessages.reverse()) {
|
||||
// Extract text content from assistant message
|
||||
const textBlocks = msg.message.content
|
||||
.filter(c => c.type === 'text')
|
||||
.map(c => ('text' in c ? c.text : ''))
|
||||
.join(' ')
|
||||
|
||||
if (textBlocks && totalChars < maxChars) {
|
||||
const remaining = maxChars - totalChars
|
||||
const truncated =
|
||||
textBlocks.length > remaining
|
||||
? textBlocks.slice(0, remaining) + '...'
|
||||
: textBlocks
|
||||
contextParts.unshift(truncated)
|
||||
totalChars += truncated.length
|
||||
}
|
||||
}
|
||||
|
||||
return contextParts.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the permission explainer feature is enabled.
|
||||
* Enabled by default; users can opt out via config.
|
||||
*/
|
||||
export function isPermissionExplainerEnabled(): boolean {
|
||||
return getGlobalConfig().permissionExplainerEnabled !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a permission explanation using Haiku with structured output.
|
||||
* Returns null if the feature is disabled, request is aborted, or an error occurs.
|
||||
*/
|
||||
export async function generatePermissionExplanation({
|
||||
toolName,
|
||||
toolInput,
|
||||
toolDescription,
|
||||
messages,
|
||||
signal,
|
||||
}: GenerateExplanationParams): Promise<PermissionExplanation | null> {
|
||||
// Check if feature is enabled
|
||||
if (!isPermissionExplainerEnabled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const formattedInput = formatToolInput(toolInput)
|
||||
const conversationContext = messages?.length
|
||||
? extractConversationContext(messages)
|
||||
: ''
|
||||
|
||||
const userPrompt = `Tool: ${toolName}
|
||||
${toolDescription ? `Description: ${toolDescription}\n` : ''}
|
||||
Input:
|
||||
${formattedInput}
|
||||
${conversationContext ? `\nRecent conversation context:\n${conversationContext}` : ''}
|
||||
|
||||
Explain this command in context.`
|
||||
|
||||
const model = getMainLoopModel()
|
||||
|
||||
// Use sideQuery with forced tool choice for guaranteed structured output
|
||||
const response = await sideQuery({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
tools: [EXPLAIN_COMMAND_TOOL],
|
||||
tool_choice: { type: 'tool', name: 'explain_command' },
|
||||
signal,
|
||||
querySource: 'permission_explainer',
|
||||
})
|
||||
|
||||
const latencyMs = Date.now() - startTime
|
||||
logForDebugging(
|
||||
`Permission explainer: API returned in ${latencyMs}ms, stop_reason=${response.stop_reason}`,
|
||||
)
|
||||
|
||||
// Extract structured data from tool use block
|
||||
const toolUseBlock = response.content.find(c => c.type === 'tool_use')
|
||||
if (toolUseBlock && toolUseBlock.type === 'tool_use') {
|
||||
logForDebugging(
|
||||
`Permission explainer: tool input: ${jsonStringify(toolUseBlock.input).slice(0, 500)}`,
|
||||
)
|
||||
const result = RiskAssessmentSchema().safeParse(toolUseBlock.input)
|
||||
|
||||
if (result.success) {
|
||||
const explanation: PermissionExplanation = {
|
||||
riskLevel: result.data.riskLevel,
|
||||
explanation: result.data.explanation,
|
||||
reasoning: result.data.reasoning,
|
||||
risk: result.data.risk,
|
||||
}
|
||||
|
||||
logEvent('tengu_permission_explainer_generated', {
|
||||
tool_name: sanitizeToolNameForAnalytics(toolName),
|
||||
risk_level: RISK_LEVEL_NUMERIC[explanation.riskLevel],
|
||||
latency_ms: latencyMs,
|
||||
})
|
||||
logForDebugging(
|
||||
`Permission explainer: ${explanation.riskLevel} risk for ${toolName} (${latencyMs}ms)`,
|
||||
)
|
||||
return explanation
|
||||
}
|
||||
}
|
||||
|
||||
// No valid JSON in response
|
||||
logEvent('tengu_permission_explainer_error', {
|
||||
tool_name: sanitizeToolNameForAnalytics(toolName),
|
||||
error_type: ERROR_TYPE_PARSE,
|
||||
latency_ms: latencyMs,
|
||||
})
|
||||
logForDebugging(`Permission explainer: no parsed output in response`)
|
||||
return null
|
||||
} catch (error) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
|
||||
// Don't log aborted requests as errors
|
||||
if (signal.aborted) {
|
||||
logForDebugging(`Permission explainer: request aborted for ${toolName}`)
|
||||
return null
|
||||
}
|
||||
|
||||
logForDebugging(`Permission explainer error: ${errorMessage(error)}`)
|
||||
logError(error)
|
||||
logEvent('tengu_permission_explainer_error', {
|
||||
tool_name: sanitizeToolNameForAnalytics(toolName),
|
||||
error_type:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? ERROR_TYPE_NETWORK
|
||||
: ERROR_TYPE_UNKNOWN,
|
||||
latency_ms: latencyMs,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user