init claude-code
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
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 type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
|
||||
import type {
|
||||
ToolPermissionContext,
|
||||
Tool as ToolType,
|
||||
ToolUseContext,
|
||||
} from '../../Tool.js'
|
||||
import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js'
|
||||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||||
import type { AssistantMessage } from '../../types/message.js'
|
||||
import type {
|
||||
PendingClassifierCheck,
|
||||
PermissionAllowDecision,
|
||||
PermissionDecisionReason,
|
||||
PermissionDenyDecision,
|
||||
} from '../../types/permissions.js'
|
||||
import { setClassifierApproval } from '../../utils/classifierApprovals.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { executePermissionRequestHooks } from '../../utils/hooks.js'
|
||||
import {
|
||||
REJECT_MESSAGE,
|
||||
REJECT_MESSAGE_WITH_REASON_PREFIX,
|
||||
SUBAGENT_REJECT_MESSAGE,
|
||||
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX,
|
||||
withMemoryCorrectionHint,
|
||||
} from '../../utils/messages.js'
|
||||
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
||||
import {
|
||||
applyPermissionUpdates,
|
||||
persistPermissionUpdates,
|
||||
supportsPersistence,
|
||||
} from '../../utils/permissions/PermissionUpdate.js'
|
||||
import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
logPermissionDecision,
|
||||
type PermissionDecisionArgs,
|
||||
} from './permissionLogging.js'
|
||||
|
||||
type PermissionApprovalSource =
|
||||
| { type: 'hook'; permanent?: boolean }
|
||||
| { type: 'user'; permanent: boolean }
|
||||
| { type: 'classifier' }
|
||||
|
||||
type PermissionRejectionSource =
|
||||
| { type: 'hook' }
|
||||
| { type: 'user_abort' }
|
||||
| { type: 'user_reject'; hasFeedback: boolean }
|
||||
|
||||
// Generic interface for permission queue operations, decoupled from React.
|
||||
// In the REPL, these are backed by React state.
|
||||
type PermissionQueueOps = {
|
||||
push(item: ToolUseConfirm): void
|
||||
remove(toolUseID: string): void
|
||||
update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
|
||||
}
|
||||
|
||||
type ResolveOnce<T> = {
|
||||
resolve(value: T): void
|
||||
isResolved(): boolean
|
||||
/**
|
||||
* Atomically check-and-mark as resolved. Returns true if this caller
|
||||
* won the race (nobody else has resolved yet), false otherwise.
|
||||
* Use this in async callbacks BEFORE awaiting, to close the window
|
||||
* between the `isResolved()` check and the actual `resolve()` call.
|
||||
*/
|
||||
claim(): boolean
|
||||
}
|
||||
|
||||
function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
|
||||
let claimed = false
|
||||
let delivered = false
|
||||
return {
|
||||
resolve(value: T) {
|
||||
if (delivered) return
|
||||
delivered = true
|
||||
claimed = true
|
||||
resolve(value)
|
||||
},
|
||||
isResolved() {
|
||||
return claimed
|
||||
},
|
||||
claim() {
|
||||
if (claimed) return false
|
||||
claimed = true
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPermissionContext(
|
||||
tool: ToolType,
|
||||
input: Record<string, unknown>,
|
||||
toolUseContext: ToolUseContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
setToolPermissionContext: (context: ToolPermissionContext) => void,
|
||||
queueOps?: PermissionQueueOps,
|
||||
) {
|
||||
const messageId = assistantMessage.message.id
|
||||
const ctx = {
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
assistantMessage,
|
||||
messageId,
|
||||
toolUseID,
|
||||
logDecision(
|
||||
args: PermissionDecisionArgs,
|
||||
opts?: {
|
||||
input?: Record<string, unknown>
|
||||
permissionPromptStartTimeMs?: number
|
||||
},
|
||||
) {
|
||||
logPermissionDecision(
|
||||
{
|
||||
tool,
|
||||
input: opts?.input ?? input,
|
||||
toolUseContext,
|
||||
messageId,
|
||||
toolUseID,
|
||||
},
|
||||
args,
|
||||
opts?.permissionPromptStartTimeMs,
|
||||
)
|
||||
},
|
||||
logCancelled() {
|
||||
logEvent('tengu_tool_use_cancelled', {
|
||||
messageID:
|
||||
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
},
|
||||
async persistPermissions(updates: PermissionUpdate[]) {
|
||||
if (updates.length === 0) return false
|
||||
persistPermissionUpdates(updates)
|
||||
const appState = toolUseContext.getAppState()
|
||||
setToolPermissionContext(
|
||||
applyPermissionUpdates(appState.toolPermissionContext, updates),
|
||||
)
|
||||
return updates.some(update => supportsPersistence(update.destination))
|
||||
},
|
||||
resolveIfAborted(resolve: (decision: PermissionDecision) => void) {
|
||||
if (!toolUseContext.abortController.signal.aborted) return false
|
||||
this.logCancelled()
|
||||
resolve(this.cancelAndAbort(undefined, true))
|
||||
return true
|
||||
},
|
||||
cancelAndAbort(
|
||||
feedback?: string,
|
||||
isAbort?: boolean,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
): PermissionDecision {
|
||||
const sub = !!toolUseContext.agentId
|
||||
const baseMessage = feedback
|
||||
? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}`
|
||||
: sub
|
||||
? SUBAGENT_REJECT_MESSAGE
|
||||
: REJECT_MESSAGE
|
||||
const message = sub ? baseMessage : withMemoryCorrectionHint(baseMessage)
|
||||
if (isAbort || (!feedback && !contentBlocks?.length && !sub)) {
|
||||
logForDebugging(
|
||||
`Aborting: tool=${tool.name} isAbort=${isAbort} hasFeedback=${!!feedback} isSubagent=${sub}`,
|
||||
)
|
||||
toolUseContext.abortController.abort()
|
||||
}
|
||||
return { behavior: 'ask', message, contentBlocks }
|
||||
},
|
||||
...(feature('BASH_CLASSIFIER')
|
||||
? {
|
||||
async tryClassifier(
|
||||
pendingClassifierCheck: PendingClassifierCheck | undefined,
|
||||
updatedInput: Record<string, unknown> | undefined,
|
||||
): Promise<PermissionDecision | null> {
|
||||
if (tool.name !== BASH_TOOL_NAME || !pendingClassifierCheck) {
|
||||
return null
|
||||
}
|
||||
const classifierDecision = await awaitClassifierAutoApproval(
|
||||
pendingClassifierCheck,
|
||||
toolUseContext.abortController.signal,
|
||||
toolUseContext.options.isNonInteractiveSession,
|
||||
)
|
||||
if (!classifierDecision) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
classifierDecision.type === 'classifier'
|
||||
) {
|
||||
const matchedRule = classifierDecision.reason.match(
|
||||
/^Allowed by prompt rule: "(.+)"$/,
|
||||
)?.[1]
|
||||
if (matchedRule) {
|
||||
setClassifierApproval(toolUseID, matchedRule)
|
||||
}
|
||||
}
|
||||
logPermissionDecision(
|
||||
{ tool, input, toolUseContext, messageId, toolUseID },
|
||||
{ decision: 'accept', source: { type: 'classifier' } },
|
||||
undefined,
|
||||
)
|
||||
return {
|
||||
behavior: 'allow' as const,
|
||||
updatedInput: updatedInput ?? input,
|
||||
userModified: false,
|
||||
decisionReason: classifierDecision,
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
async runHooks(
|
||||
permissionMode: string | undefined,
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
updatedInput?: Record<string, unknown>,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): Promise<PermissionDecision | null> {
|
||||
for await (const hookResult of executePermissionRequestHooks(
|
||||
tool.name,
|
||||
toolUseID,
|
||||
input,
|
||||
toolUseContext,
|
||||
permissionMode,
|
||||
suggestions,
|
||||
toolUseContext.abortController.signal,
|
||||
)) {
|
||||
if (hookResult.permissionRequestResult) {
|
||||
const decision = hookResult.permissionRequestResult
|
||||
if (decision.behavior === 'allow') {
|
||||
const finalInput = decision.updatedInput ?? updatedInput ?? input
|
||||
return await this.handleHookAllow(
|
||||
finalInput,
|
||||
decision.updatedPermissions ?? [],
|
||||
permissionPromptStartTimeMs,
|
||||
)
|
||||
} else if (decision.behavior === 'deny') {
|
||||
this.logDecision(
|
||||
{ decision: 'reject', source: { type: 'hook' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
if (decision.interrupt) {
|
||||
logForDebugging(
|
||||
`Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
|
||||
)
|
||||
toolUseContext.abortController.abort()
|
||||
}
|
||||
return this.buildDeny(
|
||||
decision.message || 'Permission denied by hook',
|
||||
{
|
||||
type: 'hook',
|
||||
hookName: 'PermissionRequest',
|
||||
reason: decision.message,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
buildAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
opts?: {
|
||||
userModified?: boolean
|
||||
decisionReason?: PermissionDecisionReason
|
||||
acceptFeedback?: string
|
||||
contentBlocks?: ContentBlockParam[]
|
||||
},
|
||||
): PermissionAllowDecision {
|
||||
return {
|
||||
behavior: 'allow' as const,
|
||||
updatedInput,
|
||||
userModified: opts?.userModified ?? false,
|
||||
...(opts?.decisionReason && { decisionReason: opts.decisionReason }),
|
||||
...(opts?.acceptFeedback && { acceptFeedback: opts.acceptFeedback }),
|
||||
...(opts?.contentBlocks &&
|
||||
opts.contentBlocks.length > 0 && {
|
||||
contentBlocks: opts.contentBlocks,
|
||||
}),
|
||||
}
|
||||
},
|
||||
buildDeny(
|
||||
message: string,
|
||||
decisionReason: PermissionDecisionReason,
|
||||
): PermissionDenyDecision {
|
||||
return { behavior: 'deny' as const, message, decisionReason }
|
||||
},
|
||||
async handleUserAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
decisionReason?: PermissionDecisionReason,
|
||||
): Promise<PermissionAllowDecision> {
|
||||
const acceptedPermanentUpdates =
|
||||
await this.persistPermissions(permissionUpdates)
|
||||
this.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'user', permanent: acceptedPermanentUpdates },
|
||||
},
|
||||
{ input: updatedInput, permissionPromptStartTimeMs },
|
||||
)
|
||||
const userModified = tool.inputsEquivalent
|
||||
? !tool.inputsEquivalent(input, updatedInput)
|
||||
: false
|
||||
const trimmedFeedback = feedback?.trim()
|
||||
return this.buildAllow(updatedInput, {
|
||||
userModified,
|
||||
decisionReason,
|
||||
acceptFeedback: trimmedFeedback || undefined,
|
||||
contentBlocks,
|
||||
})
|
||||
},
|
||||
async handleHookAllow(
|
||||
finalInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): Promise<PermissionAllowDecision> {
|
||||
const acceptedPermanentUpdates =
|
||||
await this.persistPermissions(permissionUpdates)
|
||||
this.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'hook', permanent: acceptedPermanentUpdates },
|
||||
},
|
||||
{ input: finalInput, permissionPromptStartTimeMs },
|
||||
)
|
||||
return this.buildAllow(finalInput, {
|
||||
decisionReason: { type: 'hook', hookName: 'PermissionRequest' },
|
||||
})
|
||||
},
|
||||
pushToQueue(item: ToolUseConfirm) {
|
||||
queueOps?.push(item)
|
||||
},
|
||||
removeFromQueue() {
|
||||
queueOps?.remove(toolUseID)
|
||||
},
|
||||
updateQueueItem(patch: Partial<ToolUseConfirm>) {
|
||||
queueOps?.update(toolUseID, patch)
|
||||
},
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
type PermissionContext = ReturnType<typeof createPermissionContext>
|
||||
|
||||
/**
|
||||
* Create a PermissionQueueOps backed by a React state setter.
|
||||
* This is the bridge between React's `setToolUseConfirmQueue` and the
|
||||
* generic queue interface used by PermissionContext.
|
||||
*/
|
||||
function createPermissionQueueOps(
|
||||
setToolUseConfirmQueue: React.Dispatch<
|
||||
React.SetStateAction<ToolUseConfirm[]>
|
||||
>,
|
||||
): PermissionQueueOps {
|
||||
return {
|
||||
push(item: ToolUseConfirm) {
|
||||
setToolUseConfirmQueue(queue => [...queue, item])
|
||||
},
|
||||
remove(toolUseID: string) {
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== toolUseID),
|
||||
)
|
||||
},
|
||||
update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.map(item =>
|
||||
item.toolUseID === toolUseID ? { ...item, ...patch } : item,
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { createPermissionContext, createPermissionQueueOps, createResolveOnce }
|
||||
export type {
|
||||
PermissionContext,
|
||||
PermissionApprovalSource,
|
||||
PermissionQueueOps,
|
||||
PermissionRejectionSource,
|
||||
ResolveOnce,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { PendingClassifierCheck } from '../../../types/permissions.js'
|
||||
import { logError } from '../../../utils/log.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import type { PermissionContext } from '../PermissionContext.js'
|
||||
|
||||
type CoordinatorPermissionParams = {
|
||||
ctx: PermissionContext
|
||||
pendingClassifierCheck?: PendingClassifierCheck | undefined
|
||||
updatedInput: Record<string, unknown> | undefined
|
||||
suggestions: PermissionUpdate[] | undefined
|
||||
permissionMode: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the coordinator worker permission flow.
|
||||
*
|
||||
* For coordinator workers, automated checks (hooks and classifier) are
|
||||
* awaited sequentially before falling through to the interactive dialog.
|
||||
*
|
||||
* Returns a PermissionDecision if the automated checks resolved the
|
||||
* permission, or null if the caller should fall through to the
|
||||
* interactive dialog.
|
||||
*/
|
||||
async function handleCoordinatorPermission(
|
||||
params: CoordinatorPermissionParams,
|
||||
): Promise<PermissionDecision | null> {
|
||||
const { ctx, updatedInput, suggestions, permissionMode } = params
|
||||
|
||||
try {
|
||||
// 1. Try permission hooks first (fast, local)
|
||||
const hookResult = await ctx.runHooks(
|
||||
permissionMode,
|
||||
suggestions,
|
||||
updatedInput,
|
||||
)
|
||||
if (hookResult) return hookResult
|
||||
|
||||
// 2. Try classifier (slow, inference -- bash only)
|
||||
const classifierResult = feature('BASH_CLASSIFIER')
|
||||
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
|
||||
: null
|
||||
if (classifierResult) {
|
||||
return classifierResult
|
||||
}
|
||||
} catch (error) {
|
||||
// If automated checks fail unexpectedly, fall through to show the dialog
|
||||
// so the user can decide manually. Non-Error throws get a context prefix
|
||||
// so the log is traceable — intentionally NOT toError(), which would drop
|
||||
// the prefix.
|
||||
if (error instanceof Error) {
|
||||
logError(error)
|
||||
} else {
|
||||
logError(new Error(`Automated permission check failed: ${String(error)}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Neither resolved (or checks failed) -- fall through to dialog below.
|
||||
// Hooks already ran, classifier already consumed.
|
||||
return null
|
||||
}
|
||||
|
||||
export { handleCoordinatorPermission }
|
||||
export type { CoordinatorPermissionParams }
|
||||
@@ -0,0 +1,536 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
||||
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
||||
import { getTerminalFocused } from '../../../ink/terminal-focus-state.js'
|
||||
import {
|
||||
CHANNEL_PERMISSION_REQUEST_METHOD,
|
||||
type ChannelPermissionRequestParams,
|
||||
findChannelEntry,
|
||||
} from '../../../services/mcp/channelNotification.js'
|
||||
import type { ChannelPermissionCallbacks } from '../../../services/mcp/channelPermissions.js'
|
||||
import {
|
||||
filterPermissionRelayClients,
|
||||
shortRequestId,
|
||||
truncateForPreview,
|
||||
} from '../../../services/mcp/channelPermissions.js'
|
||||
import { executeAsyncClassifierCheck } from '../../../tools/BashTool/bashPermissions.js'
|
||||
import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js'
|
||||
import {
|
||||
clearClassifierChecking,
|
||||
setClassifierApproval,
|
||||
setClassifierChecking,
|
||||
setYoloClassifierApproval,
|
||||
} from '../../../utils/classifierApprovals.js'
|
||||
import { errorMessage } from '../../../utils/errors.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
|
||||
import type { PermissionContext } from '../PermissionContext.js'
|
||||
import { createResolveOnce } from '../PermissionContext.js'
|
||||
|
||||
type InteractivePermissionParams = {
|
||||
ctx: PermissionContext
|
||||
description: string
|
||||
result: PermissionDecision & { behavior: 'ask' }
|
||||
awaitAutomatedChecksBeforeDialog: boolean | undefined
|
||||
bridgeCallbacks?: BridgePermissionCallbacks
|
||||
channelCallbacks?: ChannelPermissionCallbacks
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the interactive (main-agent) permission flow.
|
||||
*
|
||||
* Pushes a ToolUseConfirm entry to the confirm queue with callbacks:
|
||||
* onAbort, onAllow, onReject, recheckPermission, onUserInteraction.
|
||||
*
|
||||
* Runs permission hooks and bash classifier checks asynchronously in the
|
||||
* background, racing them against user interaction. Uses a resolve-once
|
||||
* guard and `userInteracted` flag to prevent multiple resolutions.
|
||||
*
|
||||
* This function does NOT return a Promise -- it sets up callbacks that
|
||||
* eventually call `resolve()` to resolve the outer promise owned by
|
||||
* the caller.
|
||||
*/
|
||||
function handleInteractivePermission(
|
||||
params: InteractivePermissionParams,
|
||||
resolve: (decision: PermissionDecision) => void,
|
||||
): void {
|
||||
const {
|
||||
ctx,
|
||||
description,
|
||||
result,
|
||||
awaitAutomatedChecksBeforeDialog,
|
||||
bridgeCallbacks,
|
||||
channelCallbacks,
|
||||
} = params
|
||||
|
||||
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)
|
||||
let userInteracted = false
|
||||
let checkmarkTransitionTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// Hoisted so onDismissCheckmark (Esc during checkmark window) can also
|
||||
// remove the abort listener — not just the timer callback.
|
||||
let checkmarkAbortHandler: (() => void) | undefined
|
||||
const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined
|
||||
// Hoisted so local/hook/classifier wins can remove the pending channel
|
||||
// entry. No "tell remote to dismiss" equivalent — the text sits in your
|
||||
// phone, and a stale "yes abc123" after local-resolve falls through
|
||||
// tryConsumeReply (entry gone) and gets enqueued as normal chat.
|
||||
let channelUnsubscribe: (() => void) | undefined
|
||||
|
||||
const permissionPromptStartTimeMs = Date.now()
|
||||
const displayInput = result.updatedInput ?? ctx.input
|
||||
|
||||
function clearClassifierIndicator(): void {
|
||||
if (feature('BASH_CLASSIFIER')) {
|
||||
ctx.updateQueueItem({ classifierCheckInProgress: false })
|
||||
}
|
||||
}
|
||||
|
||||
ctx.pushToQueue({
|
||||
assistantMessage: ctx.assistantMessage,
|
||||
tool: ctx.tool,
|
||||
description,
|
||||
input: displayInput,
|
||||
toolUseContext: ctx.toolUseContext,
|
||||
toolUseID: ctx.toolUseID,
|
||||
permissionResult: result,
|
||||
permissionPromptStartTimeMs,
|
||||
...(feature('BASH_CLASSIFIER')
|
||||
? {
|
||||
classifierCheckInProgress:
|
||||
!!result.pendingClassifierCheck &&
|
||||
!awaitAutomatedChecksBeforeDialog,
|
||||
}
|
||||
: {}),
|
||||
onUserInteraction() {
|
||||
// Called when user starts interacting with the permission dialog
|
||||
// (e.g., arrow keys, tab, typing feedback)
|
||||
// Hide the classifier indicator since auto-approve is no longer possible
|
||||
//
|
||||
// Grace period: ignore interactions in the first 200ms to prevent
|
||||
// accidental keypresses from canceling the classifier prematurely
|
||||
const GRACE_PERIOD_MS = 200
|
||||
if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) {
|
||||
return
|
||||
}
|
||||
userInteracted = true
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
},
|
||||
onDismissCheckmark() {
|
||||
if (checkmarkTransitionTimer) {
|
||||
clearTimeout(checkmarkTransitionTimer)
|
||||
checkmarkTransitionTimer = undefined
|
||||
if (checkmarkAbortHandler) {
|
||||
ctx.toolUseContext.abortController.signal.removeEventListener(
|
||||
'abort',
|
||||
checkmarkAbortHandler,
|
||||
)
|
||||
checkmarkAbortHandler = undefined
|
||||
}
|
||||
ctx.removeFromQueue()
|
||||
}
|
||||
},
|
||||
onAbort() {
|
||||
if (!claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'deny',
|
||||
message: 'User aborted',
|
||||
})
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
ctx.logCancelled()
|
||||
ctx.logDecision(
|
||||
{ decision: 'reject', source: { type: 'user_abort' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.cancelAndAbort(undefined, true))
|
||||
},
|
||||
async onAllow(
|
||||
updatedInput,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
) {
|
||||
if (!claim()) return // atomic check-and-mark before await
|
||||
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
updatedPermissions: permissionUpdates,
|
||||
})
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
|
||||
resolveOnce(
|
||||
await ctx.handleUserAllow(
|
||||
updatedInput,
|
||||
permissionUpdates,
|
||||
feedback,
|
||||
permissionPromptStartTimeMs,
|
||||
contentBlocks,
|
||||
result.decisionReason,
|
||||
),
|
||||
)
|
||||
},
|
||||
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
||||
if (!claim()) return
|
||||
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'deny',
|
||||
message: feedback ?? 'User denied permission',
|
||||
})
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: { type: 'user_reject', hasFeedback: !!feedback },
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks))
|
||||
},
|
||||
async recheckPermission() {
|
||||
if (isResolved()) return
|
||||
const freshResult = await hasPermissionsToUseTool(
|
||||
ctx.tool,
|
||||
ctx.input,
|
||||
ctx.toolUseContext,
|
||||
ctx.assistantMessage,
|
||||
ctx.toolUseID,
|
||||
)
|
||||
if (freshResult.behavior === 'allow') {
|
||||
// claim() (atomic check-and-mark), not isResolved() — the async
|
||||
// hasPermissionsToUseTool call above opens a window where CCR
|
||||
// could have responded in flight. Matches onAllow/onReject/hook
|
||||
// paths. cancelRequest tells CCR to dismiss its prompt — without
|
||||
// it, the web UI shows a stale prompt for a tool that's already
|
||||
// executing (particularly visible when recheck is triggered by
|
||||
// a CCR-initiated mode switch, the very case this callback exists
|
||||
// for after useReplBridge started calling it).
|
||||
if (!claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
ctx.removeFromQueue()
|
||||
ctx.logDecision({ decision: 'accept', source: 'config' })
|
||||
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Race 4: Bridge permission response from CCR (claude.ai)
|
||||
// When the bridge is connected, send the permission request to CCR and
|
||||
// subscribe for a response. Whichever side (CLI or CCR) responds first
|
||||
// wins via claim().
|
||||
//
|
||||
// All tools are forwarded — CCR's generic allow/deny modal handles any
|
||||
// tool, and can return `updatedInput` when it has a dedicated renderer
|
||||
// (e.g. plan edit). Tools whose local dialog injects fields (ReviewArtifact
|
||||
// `selected`, AskUserQuestion `answers`) tolerate the field being missing
|
||||
// so generic remote approval degrades gracefully instead of throwing.
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendRequest(
|
||||
bridgeRequestId,
|
||||
ctx.tool.name,
|
||||
displayInput,
|
||||
ctx.toolUseID,
|
||||
description,
|
||||
result.suggestions,
|
||||
result.blockedPath,
|
||||
)
|
||||
|
||||
const signal = ctx.toolUseContext.abortController.signal
|
||||
const unsubscribe = bridgeCallbacks.onResponse(
|
||||
bridgeRequestId,
|
||||
response => {
|
||||
if (!claim()) return // Local user/hook/classifier already responded
|
||||
signal.removeEventListener('abort', unsubscribe)
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
ctx.removeFromQueue()
|
||||
channelUnsubscribe?.()
|
||||
|
||||
if (response.behavior === 'allow') {
|
||||
if (response.updatedPermissions?.length) {
|
||||
void ctx.persistPermissions(response.updatedPermissions)
|
||||
}
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: {
|
||||
type: 'user',
|
||||
permanent: !!response.updatedPermissions?.length,
|
||||
},
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.buildAllow(response.updatedInput ?? displayInput))
|
||||
} else {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: {
|
||||
type: 'user_reject',
|
||||
hasFeedback: !!response.message,
|
||||
},
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.cancelAndAbort(response.message))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
signal.addEventListener('abort', unsubscribe, { once: true })
|
||||
}
|
||||
|
||||
// Channel permission relay — races alongside the bridge block above. Send a
|
||||
// permission prompt to every active channel (Telegram, iMessage, etc.) via
|
||||
// its MCP send_message tool, then race the reply against local/bridge/hook/
|
||||
// classifier. The inbound "yes abc123" is intercepted in the notification
|
||||
// handler (useManageMCPConnections.ts) BEFORE enqueue, so it never reaches
|
||||
// Claude as a conversation turn.
|
||||
//
|
||||
// Unlike the bridge block, this still guards on `requiresUserInteraction` —
|
||||
// channel replies are pure yes/no with no `updatedInput` path. In practice
|
||||
// the guard is dead code today: all three `requiresUserInteraction` tools
|
||||
// (ExitPlanMode, AskUserQuestion, ReviewArtifact) return `isEnabled()===false`
|
||||
// when channels are configured, so they never reach this handler.
|
||||
//
|
||||
// Fire-and-forget send: if callTool fails (channel down, tool missing),
|
||||
// the subscription never fires and another racer wins. Graceful degradation
|
||||
// — the local dialog is always there as the floor.
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
||||
channelCallbacks &&
|
||||
!ctx.tool.requiresUserInteraction?.()
|
||||
) {
|
||||
const channelRequestId = shortRequestId(ctx.toolUseID)
|
||||
const allowedChannels = getAllowedChannels()
|
||||
const channelClients = filterPermissionRelayClients(
|
||||
ctx.toolUseContext.getAppState().mcp.clients,
|
||||
name => findChannelEntry(name, allowedChannels) !== undefined,
|
||||
)
|
||||
|
||||
if (channelClients.length > 0) {
|
||||
// Outbound is structured too (Kenneth's symmetry ask) — server owns
|
||||
// message formatting for its platform (Telegram markdown, iMessage
|
||||
// rich text, Discord embed). CC sends the RAW parts; server composes.
|
||||
// The old callTool('send_message', {text,content,message}) triple-key
|
||||
// hack is gone — no more guessing which arg name each plugin takes.
|
||||
const params: ChannelPermissionRequestParams = {
|
||||
request_id: channelRequestId,
|
||||
tool_name: ctx.tool.name,
|
||||
description,
|
||||
input_preview: truncateForPreview(displayInput),
|
||||
}
|
||||
|
||||
for (const client of channelClients) {
|
||||
if (client.type !== 'connected') continue // refine for TS
|
||||
void client.client
|
||||
.notification({
|
||||
method: CHANNEL_PERMISSION_REQUEST_METHOD,
|
||||
params,
|
||||
})
|
||||
.catch(e => {
|
||||
logForDebugging(
|
||||
`Channel permission_request failed for ${client.name}: ${errorMessage(e)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const channelSignal = ctx.toolUseContext.abortController.signal
|
||||
// Wrap so BOTH the map delete AND the abort-listener teardown happen
|
||||
// at every call site. The 6 channelUnsubscribe?.() sites after local/
|
||||
// hook/classifier wins previously only deleted the map entry — the
|
||||
// dead closure stayed registered on the session-scoped abort signal
|
||||
// until the session ended. Not a functional bug (Map.delete is
|
||||
// idempotent), but it held the closure alive.
|
||||
const mapUnsub = channelCallbacks.onResponse(
|
||||
channelRequestId,
|
||||
response => {
|
||||
if (!claim()) return // Another racer won
|
||||
channelUnsubscribe?.() // both: map delete + listener remove
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
ctx.removeFromQueue()
|
||||
// Bridge is the other remote — tell it we're done.
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
|
||||
if (response.behavior === 'allow') {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'user', permanent: false },
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.buildAllow(displayInput))
|
||||
} else {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: { type: 'user_reject', hasFeedback: false },
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(
|
||||
ctx.cancelAndAbort(`Denied via channel ${response.fromServer}`),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
channelUnsubscribe = () => {
|
||||
mapUnsub()
|
||||
channelSignal.removeEventListener('abort', channelUnsubscribe!)
|
||||
}
|
||||
|
||||
channelSignal.addEventListener('abort', channelUnsubscribe, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Skip hooks if they were already awaited in the coordinator branch above
|
||||
if (!awaitAutomatedChecksBeforeDialog) {
|
||||
// Execute PermissionRequest hooks asynchronously
|
||||
// If hook returns a decision before user responds, apply it
|
||||
void (async () => {
|
||||
if (isResolved()) return
|
||||
const currentAppState = ctx.toolUseContext.getAppState()
|
||||
const hookDecision = await ctx.runHooks(
|
||||
currentAppState.toolPermissionContext.mode,
|
||||
result.suggestions,
|
||||
result.updatedInput,
|
||||
permissionPromptStartTimeMs,
|
||||
)
|
||||
if (!hookDecision || !claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
ctx.removeFromQueue()
|
||||
resolveOnce(hookDecision)
|
||||
})()
|
||||
}
|
||||
|
||||
// Execute bash classifier check asynchronously (if applicable)
|
||||
if (
|
||||
feature('BASH_CLASSIFIER') &&
|
||||
result.pendingClassifierCheck &&
|
||||
ctx.tool.name === BASH_TOOL_NAME &&
|
||||
!awaitAutomatedChecksBeforeDialog
|
||||
) {
|
||||
// UI indicator for "classifier running" — set here (not in
|
||||
// toolExecution.ts) so commands that auto-allow via prefix rules
|
||||
// don't flash the indicator for a split second before allow returns.
|
||||
setClassifierChecking(ctx.toolUseID)
|
||||
void executeAsyncClassifierCheck(
|
||||
result.pendingClassifierCheck,
|
||||
ctx.toolUseContext.abortController.signal,
|
||||
ctx.toolUseContext.options.isNonInteractiveSession,
|
||||
{
|
||||
shouldContinue: () => !isResolved() && !userInteracted,
|
||||
onComplete: () => {
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
},
|
||||
onAllow: decisionReason => {
|
||||
if (!claim()) return
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
|
||||
const matchedRule =
|
||||
decisionReason.type === 'classifier'
|
||||
? (decisionReason.reason.match(
|
||||
/^Allowed by prompt rule: "(.+)"$/,
|
||||
)?.[1] ?? decisionReason.reason)
|
||||
: undefined
|
||||
|
||||
// Show auto-approved transition with dimmed options
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
ctx.updateQueueItem({
|
||||
classifierCheckInProgress: false,
|
||||
classifierAutoApproved: true,
|
||||
classifierMatchedRule: matchedRule,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
decisionReason.type === 'classifier'
|
||||
) {
|
||||
if (decisionReason.classifier === 'auto-mode') {
|
||||
setYoloClassifierApproval(ctx.toolUseID, decisionReason.reason)
|
||||
} else if (matchedRule) {
|
||||
setClassifierApproval(ctx.toolUseID, matchedRule)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logDecision(
|
||||
{ decision: 'accept', source: { type: 'classifier' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(ctx.buildAllow(ctx.input, { decisionReason }))
|
||||
|
||||
// Keep checkmark visible, then remove dialog.
|
||||
// 3s if terminal is focused (user can see it), 1s if not.
|
||||
// User can dismiss early with Esc via onDismissCheckmark.
|
||||
const signal = ctx.toolUseContext.abortController.signal
|
||||
checkmarkAbortHandler = () => {
|
||||
if (checkmarkTransitionTimer) {
|
||||
clearTimeout(checkmarkTransitionTimer)
|
||||
checkmarkTransitionTimer = undefined
|
||||
// Sibling Bash error can fire this (StreamingToolExecutor
|
||||
// cascades via siblingAbortController) — must drop the
|
||||
// cosmetic ✓ dialog or it blocks the next queued item.
|
||||
ctx.removeFromQueue()
|
||||
}
|
||||
}
|
||||
const checkmarkMs = getTerminalFocused() ? 3000 : 1000
|
||||
checkmarkTransitionTimer = setTimeout(() => {
|
||||
checkmarkTransitionTimer = undefined
|
||||
if (checkmarkAbortHandler) {
|
||||
signal.removeEventListener('abort', checkmarkAbortHandler)
|
||||
checkmarkAbortHandler = undefined
|
||||
}
|
||||
ctx.removeFromQueue()
|
||||
}, checkmarkMs)
|
||||
signal.addEventListener('abort', checkmarkAbortHandler, {
|
||||
once: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
).catch(error => {
|
||||
// Log classifier API errors for debugging but don't propagate them as interruptions
|
||||
// These errors can be network failures, rate limits, or model issues - not user cancellations
|
||||
logForDebugging(`Async classifier check failed: ${errorMessage(error)}`, {
|
||||
level: 'error',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
export { handleInteractivePermission }
|
||||
export type { InteractivePermissionParams }
|
||||
@@ -0,0 +1,159 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import type { PendingClassifierCheck } from '../../../types/permissions.js'
|
||||
import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js'
|
||||
import { toError } from '../../../utils/errors.js'
|
||||
import { logError } from '../../../utils/log.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
createPermissionRequest,
|
||||
isSwarmWorker,
|
||||
sendPermissionRequestViaMailbox,
|
||||
} from '../../../utils/swarm/permissionSync.js'
|
||||
import { registerPermissionCallback } from '../../useSwarmPermissionPoller.js'
|
||||
import type { PermissionContext } from '../PermissionContext.js'
|
||||
import { createResolveOnce } from '../PermissionContext.js'
|
||||
|
||||
type SwarmWorkerPermissionParams = {
|
||||
ctx: PermissionContext
|
||||
description: string
|
||||
pendingClassifierCheck?: PendingClassifierCheck | undefined
|
||||
updatedInput: Record<string, unknown> | undefined
|
||||
suggestions: PermissionUpdate[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the swarm worker permission flow.
|
||||
*
|
||||
* When running as a swarm worker:
|
||||
* 1. Tries classifier auto-approval for bash commands
|
||||
* 2. Forwards the permission request to the leader via mailbox
|
||||
* 3. Registers callbacks for when the leader responds
|
||||
* 4. Sets the pending indicator while waiting
|
||||
*
|
||||
* Returns a PermissionDecision if the classifier auto-approves,
|
||||
* or a Promise that resolves when the leader responds.
|
||||
* Returns null if swarms are not enabled or this is not a swarm worker,
|
||||
* so the caller can fall through to interactive handling.
|
||||
*/
|
||||
async function handleSwarmWorkerPermission(
|
||||
params: SwarmWorkerPermissionParams,
|
||||
): Promise<PermissionDecision | null> {
|
||||
if (!isAgentSwarmsEnabled() || !isSwarmWorker()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { ctx, description, updatedInput, suggestions } = params
|
||||
|
||||
// For bash commands, try classifier auto-approval before forwarding to
|
||||
// the leader. Agents await the classifier result (rather than racing it
|
||||
// against user interaction like the main agent).
|
||||
const classifierResult = feature('BASH_CLASSIFIER')
|
||||
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
|
||||
: null
|
||||
if (classifierResult) {
|
||||
return classifierResult
|
||||
}
|
||||
|
||||
// Forward permission request to the leader via mailbox
|
||||
try {
|
||||
const clearPendingRequest = (): void =>
|
||||
ctx.toolUseContext.setAppState(prev => ({
|
||||
...prev,
|
||||
pendingWorkerRequest: null,
|
||||
}))
|
||||
|
||||
const decision = await new Promise<PermissionDecision>(resolve => {
|
||||
const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
|
||||
|
||||
// Create the permission request
|
||||
const request = createPermissionRequest({
|
||||
toolName: ctx.tool.name,
|
||||
toolUseId: ctx.toolUseID,
|
||||
input: ctx.input,
|
||||
description,
|
||||
permissionSuggestions: suggestions,
|
||||
})
|
||||
|
||||
// Register callback BEFORE sending the request to avoid race condition
|
||||
// where leader responds before callback is registered
|
||||
registerPermissionCallback({
|
||||
requestId: request.id,
|
||||
toolUseId: ctx.toolUseID,
|
||||
async onAllow(
|
||||
allowedInput: Record<string, unknown> | undefined,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
) {
|
||||
if (!claim()) return // atomic check-and-mark before await
|
||||
clearPendingRequest()
|
||||
|
||||
// Merge the updated input with the original input
|
||||
const finalInput =
|
||||
allowedInput && Object.keys(allowedInput).length > 0
|
||||
? allowedInput
|
||||
: ctx.input
|
||||
|
||||
resolveOnce(
|
||||
await ctx.handleUserAllow(
|
||||
finalInput,
|
||||
permissionUpdates,
|
||||
feedback,
|
||||
undefined,
|
||||
contentBlocks,
|
||||
),
|
||||
)
|
||||
},
|
||||
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
||||
if (!claim()) return
|
||||
clearPendingRequest()
|
||||
|
||||
ctx.logDecision({
|
||||
decision: 'reject',
|
||||
source: { type: 'user_reject', hasFeedback: !!feedback },
|
||||
})
|
||||
|
||||
resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks))
|
||||
},
|
||||
})
|
||||
|
||||
// Now that callback is registered, send the request to the leader
|
||||
void sendPermissionRequestViaMailbox(request)
|
||||
|
||||
// Show visual indicator that we're waiting for leader approval
|
||||
ctx.toolUseContext.setAppState(prev => ({
|
||||
...prev,
|
||||
pendingWorkerRequest: {
|
||||
toolName: ctx.tool.name,
|
||||
toolUseId: ctx.toolUseID,
|
||||
description,
|
||||
},
|
||||
}))
|
||||
|
||||
// If the abort signal fires while waiting for the leader response,
|
||||
// resolve the promise with a cancel decision so it does not hang.
|
||||
ctx.toolUseContext.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
if (!claim()) return
|
||||
clearPendingRequest()
|
||||
ctx.logCancelled()
|
||||
resolveOnce(ctx.cancelAndAbort(undefined, true))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
return decision
|
||||
} catch (error) {
|
||||
// If swarm permission submission fails, fall back to local handling
|
||||
logError(toError(error))
|
||||
// Continue to local UI handling below
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { handleSwarmWorkerPermission }
|
||||
export type { SwarmWorkerPermissionParams }
|
||||
@@ -0,0 +1,238 @@
|
||||
// Centralized analytics/telemetry logging for tool permission decisions.
|
||||
// All permission approve/reject events flow through logPermissionDecision(),
|
||||
// which fans out to Statsig analytics, OTel telemetry, and code-edit metrics.
|
||||
import { feature } from 'bun:bundle'
|
||||
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 { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
|
||||
import { getLanguageName } from '../../utils/cliHighlight.js'
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import type {
|
||||
PermissionApprovalSource,
|
||||
PermissionRejectionSource,
|
||||
} from './PermissionContext.js'
|
||||
|
||||
type PermissionLogContext = {
|
||||
tool: ToolType
|
||||
input: unknown
|
||||
toolUseContext: ToolUseContext
|
||||
messageId: string
|
||||
toolUseID: string
|
||||
}
|
||||
|
||||
// Discriminated union: 'accept' pairs with approval sources, 'reject' with rejection sources
|
||||
type PermissionDecisionArgs =
|
||||
| { decision: 'accept'; source: PermissionApprovalSource | 'config' }
|
||||
| { decision: 'reject'; source: PermissionRejectionSource | 'config' }
|
||||
|
||||
const CODE_EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit']
|
||||
|
||||
function isCodeEditingTool(toolName: string): boolean {
|
||||
return CODE_EDITING_TOOLS.includes(toolName)
|
||||
}
|
||||
|
||||
// Builds OTel counter attributes for code editing tools, enriching with
|
||||
// language when the tool's target file path can be extracted from input
|
||||
async function buildCodeEditToolAttributes(
|
||||
tool: ToolType,
|
||||
input: unknown,
|
||||
decision: 'accept' | 'reject',
|
||||
source: string,
|
||||
): Promise<Record<string, string>> {
|
||||
// Derive language from file path if the tool exposes one (e.g., Edit, Write)
|
||||
let language: string | undefined
|
||||
if (tool.getPath && input) {
|
||||
const parseResult = tool.inputSchema.safeParse(input)
|
||||
if (parseResult.success) {
|
||||
const filePath = tool.getPath(parseResult.data)
|
||||
if (filePath) {
|
||||
language = await getLanguageName(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
source,
|
||||
tool_name: tool.name,
|
||||
...(language && { language }),
|
||||
}
|
||||
}
|
||||
|
||||
// Flattens structured source into a string label for analytics/OTel events
|
||||
function sourceToString(
|
||||
source: PermissionApprovalSource | PermissionRejectionSource,
|
||||
): string {
|
||||
if (
|
||||
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
|
||||
source.type === 'classifier'
|
||||
) {
|
||||
return 'classifier'
|
||||
}
|
||||
switch (source.type) {
|
||||
case 'hook':
|
||||
return 'hook'
|
||||
case 'user':
|
||||
return source.permanent ? 'user_permanent' : 'user_temporary'
|
||||
case 'user_abort':
|
||||
return 'user_abort'
|
||||
case 'user_reject':
|
||||
return 'user_reject'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function baseMetadata(
|
||||
messageId: string,
|
||||
toolName: string,
|
||||
waitMs: number | undefined,
|
||||
): { [key: string]: boolean | number | undefined } {
|
||||
return {
|
||||
messageID:
|
||||
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName: sanitizeToolNameForAnalytics(toolName),
|
||||
sandboxEnabled: SandboxManager.isSandboxingEnabled(),
|
||||
// Only include wait time when the user was actually prompted (not auto-approved)
|
||||
...(waitMs !== undefined && { waiting_for_user_permission_ms: waitMs }),
|
||||
}
|
||||
}
|
||||
|
||||
// Emits a distinct analytics event name per approval source for funnel analysis
|
||||
function logApprovalEvent(
|
||||
tool: ToolType,
|
||||
messageId: string,
|
||||
source: PermissionApprovalSource | 'config',
|
||||
waitMs: number | undefined,
|
||||
): void {
|
||||
if (source === 'config') {
|
||||
// Auto-approved by allowlist in settings -- no user wait time
|
||||
logEvent(
|
||||
'tengu_tool_use_granted_in_config',
|
||||
baseMetadata(messageId, tool.name, undefined),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
|
||||
source.type === 'classifier'
|
||||
) {
|
||||
logEvent(
|
||||
'tengu_tool_use_granted_by_classifier',
|
||||
baseMetadata(messageId, tool.name, waitMs),
|
||||
)
|
||||
return
|
||||
}
|
||||
switch (source.type) {
|
||||
case 'user':
|
||||
logEvent(
|
||||
source.permanent
|
||||
? 'tengu_tool_use_granted_in_prompt_permanent'
|
||||
: 'tengu_tool_use_granted_in_prompt_temporary',
|
||||
baseMetadata(messageId, tool.name, waitMs),
|
||||
)
|
||||
break
|
||||
case 'hook':
|
||||
logEvent('tengu_tool_use_granted_by_permission_hook', {
|
||||
...baseMetadata(messageId, tool.name, waitMs),
|
||||
permanent: source.permanent ?? false,
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Rejections share a single event name, differentiated by metadata fields
|
||||
function logRejectionEvent(
|
||||
tool: ToolType,
|
||||
messageId: string,
|
||||
source: PermissionRejectionSource | 'config',
|
||||
waitMs: number | undefined,
|
||||
): void {
|
||||
if (source === 'config') {
|
||||
// Denied by denylist in settings
|
||||
logEvent(
|
||||
'tengu_tool_use_denied_in_config',
|
||||
baseMetadata(messageId, tool.name, undefined),
|
||||
)
|
||||
return
|
||||
}
|
||||
logEvent('tengu_tool_use_rejected_in_prompt', {
|
||||
...baseMetadata(messageId, tool.name, waitMs),
|
||||
// Distinguish hook rejections from user rejections via separate fields
|
||||
...(source.type === 'hook'
|
||||
? { isHook: true }
|
||||
: {
|
||||
hasFeedback:
|
||||
source.type === 'user_reject' ? source.hasFeedback : false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Single entry point for all permission decision logging. Called by permission
|
||||
// handlers after every approve/reject. Fans out to: analytics events, OTel
|
||||
// telemetry, code-edit OTel counters, and toolUseContext decision storage.
|
||||
function logPermissionDecision(
|
||||
ctx: PermissionLogContext,
|
||||
args: PermissionDecisionArgs,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): void {
|
||||
const { tool, input, toolUseContext, messageId, toolUseID } = ctx
|
||||
const { decision, source } = args
|
||||
|
||||
const waiting_for_user_permission_ms =
|
||||
permissionPromptStartTimeMs !== undefined
|
||||
? Date.now() - permissionPromptStartTimeMs
|
||||
: undefined
|
||||
|
||||
// Log the analytics event
|
||||
if (args.decision === 'accept') {
|
||||
logApprovalEvent(
|
||||
tool,
|
||||
messageId,
|
||||
args.source,
|
||||
waiting_for_user_permission_ms,
|
||||
)
|
||||
} else {
|
||||
logRejectionEvent(
|
||||
tool,
|
||||
messageId,
|
||||
args.source,
|
||||
waiting_for_user_permission_ms,
|
||||
)
|
||||
}
|
||||
|
||||
const sourceString = source === 'config' ? 'config' : sourceToString(source)
|
||||
|
||||
// Track code editing tool metrics
|
||||
if (isCodeEditingTool(tool.name)) {
|
||||
void buildCodeEditToolAttributes(tool, input, decision, sourceString).then(
|
||||
attributes => getCodeEditToolDecisionCounter()?.add(1, attributes),
|
||||
)
|
||||
}
|
||||
|
||||
// Persist decision on the context so downstream code can inspect what happened
|
||||
if (!toolUseContext.toolDecisions) {
|
||||
toolUseContext.toolDecisions = new Map()
|
||||
}
|
||||
toolUseContext.toolDecisions.set(toolUseID, {
|
||||
source: sourceString,
|
||||
decision,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
void logOTelEvent('tool_decision', {
|
||||
decision,
|
||||
source: sourceString,
|
||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
}
|
||||
|
||||
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
|
||||
export type { PermissionLogContext, PermissionDecisionArgs }
|
||||
Reference in New Issue
Block a user