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