mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
* diagnostics(issue-1830): trace interruption causality * test(issue-1830): lock interruption ownership matrix * fix(codex): preserve stream deadline contract * fix(diagnostics): harden interruption trace lifecycle Refs #1830 * fix(diagnostics): harden interruption trace settlement Refs #1830 * fix(diagnostics): preserve interruption causality * fix(diagnostics): address interruption trace review * fix(diagnostics): preserve tracing observer contracts * fix(diagnostics): preserve interruption trace contracts * test(permissions): cover interactive hook interrupts
581 lines
19 KiB
TypeScript
581 lines
19 KiB
TypeScript
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 { requestAbort } from '../../utils/interruptionTrace.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 {
|
|
checkPlanModePermissions,
|
|
checkRuleBasedPermissions,
|
|
revalidatePlanModePermissionAllowWithRaceGuard,
|
|
samePermissionAskConstraint,
|
|
} from '../../utils/permissions/permissions.js'
|
|
import {
|
|
applyPermissionUpdate,
|
|
filterPermissionRequestHookUpdates,
|
|
persistPermissionUpdates,
|
|
supportsPersistence,
|
|
} from '../../utils/permissions/PermissionUpdate.js'
|
|
import {
|
|
applyPermissionUpdateToLiveContext,
|
|
applyPermissionUpdatesToLiveContext,
|
|
getPermissionModeChangeRequestDecision,
|
|
} from '../../utils/permissions/permissionSetup.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[],
|
|
hookPlanModeWasActive?: boolean,
|
|
) {
|
|
if (updates.length === 0) return false
|
|
const appState = toolUseContext.getAppState()
|
|
const validatedUpdates: PermissionUpdate[] = []
|
|
let nextContextForValidation = appState.toolPermissionContext
|
|
for (const update of updates) {
|
|
if (update.type === 'setMode') {
|
|
const modeDecision = await getPermissionModeChangeRequestDecision({
|
|
mode: update.mode,
|
|
toolPermissionContext: nextContextForValidation,
|
|
requireLocalConfirmation: false,
|
|
})
|
|
if (modeDecision.status === 'blocked') {
|
|
logForDebugging(
|
|
`Skipping permission mode update for ${update.mode}: ${modeDecision.error}`,
|
|
{ level: 'warn' },
|
|
)
|
|
continue
|
|
}
|
|
}
|
|
validatedUpdates.push(update)
|
|
// Validation/staging must stay pure: defer live transition side effects
|
|
// until we actually commit the updates below.
|
|
nextContextForValidation = applyPermissionUpdate(
|
|
nextContextForValidation,
|
|
update,
|
|
)
|
|
}
|
|
if (validatedUpdates.length === 0) return false
|
|
const latestAppState = toolUseContext.getAppState()
|
|
const updatesToApply =
|
|
hookPlanModeWasActive === undefined
|
|
? validatedUpdates
|
|
: filterPermissionRequestHookUpdates(
|
|
validatedUpdates,
|
|
hookPlanModeWasActive ||
|
|
latestAppState.toolPermissionContext.mode === 'plan',
|
|
)
|
|
if (updatesToApply.length === 0) return false
|
|
const updatedContext = applyPermissionUpdatesToLiveContext(
|
|
latestAppState.toolPermissionContext,
|
|
updatesToApply,
|
|
)
|
|
persistPermissionUpdates(updatesToApply)
|
|
setToolPermissionContext(updatedContext)
|
|
return updatesToApply.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[],
|
|
abortTrace?: { source: string; causalEventId?: string },
|
|
): 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}`,
|
|
)
|
|
requestAbort(toolUseContext.abortController, 'interrupt', {
|
|
source: abortTrace?.source ?? 'permission_abort',
|
|
causalEventId: abortTrace?.causalEventId,
|
|
subsystem: 'tool_permission',
|
|
controllerRole: 'query-root',
|
|
})
|
|
}
|
|
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 planModeWasActive =
|
|
toolUseContext.getAppState().toolPermissionContext.mode ===
|
|
'plan'
|
|
const classifierDecision = await awaitClassifierAutoApproval(
|
|
pendingClassifierCheck,
|
|
toolUseContext.abortController.signal,
|
|
toolUseContext.options.isNonInteractiveSession,
|
|
)
|
|
if (!classifierDecision) {
|
|
return null
|
|
}
|
|
const finalInput = updatedInput ?? input
|
|
const planModeDecision =
|
|
await revalidatePlanModePermissionAllowWithRaceGuard(
|
|
tool,
|
|
input,
|
|
finalInput,
|
|
toolUseContext,
|
|
planModeWasActive,
|
|
)
|
|
if (planModeDecision) {
|
|
return planModeDecision
|
|
}
|
|
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: finalInput,
|
|
userModified: false,
|
|
decisionReason: classifierDecision,
|
|
}
|
|
},
|
|
}
|
|
: {}),
|
|
async runHooks(
|
|
permissionMode: string | undefined,
|
|
suggestions: PermissionUpdate[] | undefined,
|
|
updatedInput?: Record<string, unknown>,
|
|
permissionPromptStartTimeMs?: number,
|
|
): Promise<PermissionDecision | null> {
|
|
const enforcePlanMode =
|
|
toolUseContext.getAppState().toolPermissionContext.mode === 'plan'
|
|
const candidateInput = updatedInput ?? input
|
|
const candidateRuleDecision = enforcePlanMode
|
|
? await checkRuleBasedPermissions(
|
|
tool,
|
|
candidateInput,
|
|
toolUseContext,
|
|
)
|
|
: null
|
|
if (candidateRuleDecision?.behavior === 'deny') {
|
|
return candidateRuleDecision
|
|
}
|
|
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
|
|
const finalPlanMode =
|
|
enforcePlanMode ||
|
|
toolUseContext.getAppState().toolPermissionContext.mode ===
|
|
'plan'
|
|
const finalRuleDecision = finalPlanMode
|
|
? await checkRuleBasedPermissions(
|
|
tool,
|
|
finalInput,
|
|
toolUseContext,
|
|
)
|
|
: null
|
|
if (finalRuleDecision?.behavior === 'deny') {
|
|
return finalRuleDecision
|
|
}
|
|
const planModeDecision = await checkPlanModePermissions(
|
|
tool,
|
|
finalInput,
|
|
toolUseContext,
|
|
enforcePlanMode,
|
|
)
|
|
if (planModeDecision) {
|
|
return planModeDecision
|
|
}
|
|
if (
|
|
finalRuleDecision?.behavior === 'ask' &&
|
|
!samePermissionAskConstraint(
|
|
candidateRuleDecision,
|
|
finalRuleDecision,
|
|
)
|
|
) {
|
|
return finalRuleDecision
|
|
}
|
|
return await this.handleHookAllow(
|
|
finalInput,
|
|
filterPermissionRequestHookUpdates(
|
|
decision.updatedPermissions ?? [],
|
|
enforcePlanMode ||
|
|
toolUseContext.getAppState().toolPermissionContext.mode ===
|
|
'plan',
|
|
),
|
|
permissionPromptStartTimeMs,
|
|
enforcePlanMode,
|
|
)
|
|
} 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}`,
|
|
)
|
|
requestAbort(toolUseContext.abortController, 'interrupt', {
|
|
source: 'permission_hook',
|
|
subsystem: 'tool_permission',
|
|
controllerRole: 'query-root',
|
|
})
|
|
}
|
|
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<PermissionDecision> {
|
|
const planModeWasActive =
|
|
toolUseContext.getAppState().toolPermissionContext.mode === 'plan'
|
|
const revalidation =
|
|
await revalidatePlanModePermissionAllowWithRaceGuard(
|
|
tool,
|
|
input,
|
|
updatedInput,
|
|
toolUseContext,
|
|
planModeWasActive,
|
|
)
|
|
if (revalidation) {
|
|
return revalidation
|
|
}
|
|
const acceptedPermanentUpdates =
|
|
await this.persistPermissions(permissionUpdates, planModeWasActive)
|
|
const finalRevalidation =
|
|
await revalidatePlanModePermissionAllowWithRaceGuard(
|
|
tool,
|
|
input,
|
|
updatedInput,
|
|
toolUseContext,
|
|
planModeWasActive,
|
|
)
|
|
if (finalRevalidation) {
|
|
return finalRevalidation
|
|
}
|
|
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,
|
|
planModeWasActive = false,
|
|
): Promise<PermissionDecision> {
|
|
const acceptedPermanentUpdates =
|
|
await this.persistPermissions(permissionUpdates, planModeWasActive)
|
|
const postUpdatePlanModeDecision =
|
|
await revalidatePlanModePermissionAllowWithRaceGuard(
|
|
tool,
|
|
input,
|
|
finalInput,
|
|
toolUseContext,
|
|
planModeWasActive ||
|
|
toolUseContext.getAppState().toolPermissionContext.mode === 'plan',
|
|
)
|
|
if (postUpdatePlanModeDecision) {
|
|
return postUpdatePlanModeDecision
|
|
}
|
|
this.logDecision(
|
|
{
|
|
decision: 'accept',
|
|
source: { type: 'hook', permanent: acceptedPermanentUpdates },
|
|
},
|
|
{ input: finalInput, permissionPromptStartTimeMs },
|
|
)
|
|
return this.buildAllow(finalInput, {
|
|
decisionReason: { type: 'hook', hookName: 'PermissionRequest' },
|
|
})
|
|
},
|
|
async handleClassifierAllow(
|
|
finalInput: Record<string, unknown>,
|
|
decisionReason: PermissionDecisionReason,
|
|
permissionPromptStartTimeMs?: number,
|
|
planModeWasActive = false,
|
|
): Promise<PermissionDecision> {
|
|
const planModeDecision =
|
|
await revalidatePlanModePermissionAllowWithRaceGuard(
|
|
tool,
|
|
input,
|
|
finalInput,
|
|
toolUseContext,
|
|
planModeWasActive,
|
|
)
|
|
if (planModeDecision) {
|
|
return planModeDecision
|
|
}
|
|
this.logDecision(
|
|
{ decision: 'accept', source: { type: 'classifier' } },
|
|
{ input: finalInput, permissionPromptStartTimeMs },
|
|
)
|
|
return this.buildAllow(finalInput, { decisionReason })
|
|
},
|
|
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,
|
|
}
|