Why this matters
Permissions are the gate between “the model asked to do something” and “the tool is allowed to run.” The important beginner lesson is that Claude Code does not answer that question in one place. It uses a shared decision shape, a top-level hook, a permission context object, and then several runtime-specific handlers.
Big picture first
The real flow is:
hasPermissionsToUseTool(...)returns a decision object whosebehaviorisallow,ask, ordeny.useCanUseTool()reads that decision and decides whether the request is already done or needs more handling.createPermissionContext(...)builds the shared helper object for logging, persistence, queue updates, and abort handling.- If the decision is
ask, the request may still be resolved by coordinator automation, a swarm leader, a speculative Bash classifier, or the interactive dialog queue.
So the permission model is not just “show a yes/no prompt.” It is a branching pipeline with several chances to resolve the request before the normal dialog appears.
Start with the real decision shapes
Permission decisions
The three shapes that the rest of the permission code passes around.
export type PermissionAllowDecision<
Input extends { [key: string]: unknown } = { [key: string]: unknown },
> = {
behavior: 'allow'
updatedInput?: Input
userModified?: boolean
decisionReason?: PermissionDecisionReason
toolUseID?: string
acceptFeedback?: string
contentBlocks?: ContentBlockParam[]
}
export type PermissionAskDecision<
Input extends { [key: string]: unknown } = { [key: string]: unknown },
> = {
behavior: 'ask'
message: string
updatedInput?: Input
decisionReason?: PermissionDecisionReason
suggestions?: PermissionUpdate[]
blockedPath?: string
metadata?: PermissionMetadata
isBashSecurityCheckForMisparsing?: boolean
pendingClassifierCheck?: PendingClassifierCheck
contentBlocks?: ContentBlockParam[]
}
export type PermissionDenyDecision = {
behavior: 'deny'
message: string
decisionReason: PermissionDecisionReason
toolUseID?: string
} This is why the rest of the chapter keeps saying “three-way decision” instead
of “yes or no.” ask is a first-class result, not a fallback bolted on later.
useCanUseTool() is the top-level traffic cop
The hook starts by building shared context and delegating the first decision to
hasPermissionsToUseTool(...):
const ctx = createPermissionContext(
tool,
input,
toolUseContext,
assistantMessage,
toolUseID,
setToolPermissionContext,
createPermissionQueueOps(setToolUseConfirmQueue),
)
if (ctx.resolveIfAborted(resolve)) {
return
}
const decisionPromise =
forceDecision !== undefined
? Promise.resolve(forceDecision)
: hasPermissionsToUseTool(
tool,
input,
toolUseContext,
assistantMessage,
toolUseID,
)
return decisionPromise.then(async result => {
if (result.behavior === "allow") {
if (ctx.resolveIfAborted(resolve)) {
return
}
ctx.logDecision({
decision: "accept",
source: "config"
})
resolve(ctx.buildAllow(result.updatedInput ?? input, {
decisionReason: result.decisionReason
}))
return
}
There are two practical ideas here:
useCanUseTool()does not do the primary permission check itself. It askshasPermissionsToUseTool(...)for aPermissionDecision.- A plain
allowresult short-circuits the rest of the flow. The hook logs it and resolves withctx.buildAllow(...)immediately.
If the result is not allow, the hook still computes a human-readable
description before branching, because both deny and ask paths need
user-facing context.
Once the initial decision is not allow, useCanUseTool() computes the tool
description and then branches like this:
switch (result.behavior) {
case "deny":
{
logPermissionDecision({
tool,
input,
toolUseContext,
messageId: ctx.messageId,
toolUseID
}, {
decision: "reject",
source: "config"
})
resolve(result)
return
}
case "ask":
{
if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
const coordinatorDecision = await handleCoordinatorPermission({
ctx,
...(feature("BASH_CLASSIFIER") ? {
pendingClassifierCheck: result.pendingClassifierCheck
} : {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions,
permissionMode: appState.toolPermissionContext.mode
})
if (coordinatorDecision) {
resolve(coordinatorDecision)
return
}
}
if (ctx.resolveIfAborted(resolve)) {
return
}
const swarmDecision = await handleSwarmWorkerPermission({
ctx,
description,
...(feature("BASH_CLASSIFIER") ? {
pendingClassifierCheck: result.pendingClassifierCheck
} : {}),
updatedInput: result.updatedInput,
suggestions: result.suggestions
})
if (swarmDecision) {
resolve(swarmDecision)
return
}
That is the real fork in the road:
denyresolves immediately after logging, and can also trigger the auto-mode denial notification path.askdoes not jump straight to UI. It first gives coordinator automation a chance, then swarm-worker forwarding.
There is one more important branch before the normal dialog. For Bash requests
with pendingClassifierCheck, the main agent gives the speculative classifier a
short grace period and only then falls through:
if (
feature("BASH_CLASSIFIER") &&
result.pendingClassifierCheck &&
tool.name === BASH_TOOL_NAME &&
!appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog
) {
const speculativePromise = peekSpeculativeClassifierCheck(
(input as { command: string }).command,
)
if (speculativePromise) {
const raceResult = await Promise.race([
speculativePromise.then(r => ({
type: "result" as const,
result: r
})),
new Promise(res =>
setTimeout(res, 2000, { type: "timeout" as const }),
),
])
if (
raceResult.type === "result" &&
raceResult.result.matches &&
raceResult.result.confidence === "high" &&
feature("BASH_CLASSIFIER")
) {
consumeSpeculativeClassifierCheck((input as { command: string }).command)
resolve(ctx.buildAllow(result.updatedInput ?? input as Record<string, unknown>, {
decisionReason: {
type: "classifier" as const,
classifier: "bash_allow" as const,
reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`
}
}))
return
}
}
}
handleInteractivePermission({
ctx,
description,
result,
awaitAutomatedChecksBeforeDialog:
appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog,
bridgeCallbacks: feature("BRIDGE_MODE")
? appState.replBridgePermissionCallbacks
: undefined,
channelCallbacks:
feature("KAIROS") || feature("KAIROS_CHANNELS")
? appState.channelPermissionCallbacks
: undefined
}, resolve)
So even inside the ask branch, the code still tries one more automatic win
before it falls through to handleInteractivePermission(...).
createPermissionContext() builds the shared workbench
The ask handlers all rely on the same context object:
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,
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 }
},
createPermissionContext() is not just a bag of fields. It centralizes the
reusable operations that every permission path needs:
resolveIfAborted(...)prevents stale permission work from continuing after cancellation.cancelAndAbort(...)builds the rejection message and may abort the controller.- The returned object keeps the original
tool,input,toolUseContext,assistantMessage, andtoolUseIDtogether so downstream handlers do not need a huge parameter list.
One subtle but important detail: cancelAndAbort(...) returns
{ behavior: 'ask', message, contentBlocks }, not a deny result. That is the
real shape in the source tree.
The same context object also owns hook execution and the helper builders for the final result objects:
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 }
},
That design is why the handlers stay relatively small. They do not need to know how to log every case or build every result shape from scratch.
The three ask handlers do different jobs
Coordinator path: run hooks, then classifier, then fall through
Coordinator workers use a fully sequential automated path:
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 (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.
return null
}
Layman-friendly summary: coordinator mode waits for the automated checks to finish first. Only if hooks and classifier both fail to resolve the request does control return to the caller for a real prompt.
Swarm-worker path: ask the leader through the mailbox
Swarm workers do not use the normal local dialog path first. They try the classifier and then forward the request upward:
async function handleSwarmWorkerPermission(
params: SwarmWorkerPermissionParams,
): Promise<PermissionDecision | null> {
if (!isAgentSwarmsEnabled() || !isSwarmWorker()) {
return null
}
const { ctx, description, updatedInput, suggestions } = params
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) {
return classifierResult
}
try {
const clearPendingRequest = (): void =>
ctx.toolUseContext.setAppState(prev => ({
...prev,
pendingWorkerRequest: null,
}))
const decision = await new Promise<PermissionDecision>(resolve => {
const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
const request = createPermissionRequest({
toolName: ctx.tool.name,
toolUseId: ctx.toolUseID,
input: ctx.input,
description,
permissionSuggestions: suggestions,
})
registerPermissionCallback({
requestId: request.id,
toolUseId: ctx.toolUseID,
async onAllow(
allowedInput: Record<string, unknown> | undefined,
permissionUpdates: PermissionUpdate[],
feedback?: string,
contentBlocks?: ContentBlockParam[],
) {
if (!claim()) return
clearPendingRequest()
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))
},
})
This is the real swarm story: the worker packages the request, registers a callback, sends the request to the leader, and waits for the leader’s reply. That is different from both coordinator automation and the local interactive dialog.
Interactive path: push a queue item and race several callbacks
The main-agent dialog handler is queue-based:
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
let checkmarkAbortHandler: (() => void) | undefined
const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined
let channelUnsubscribe: (() => void) | undefined
const permissionPromptStartTimeMs = Date.now()
const displayInput = result.updatedInput ?? ctx.input
ctx.pushToQueue({
assistantMessage: ctx.assistantMessage,
tool: ctx.tool,
description,
input: displayInput,
toolUseContext: ctx.toolUseContext,
toolUseID: ctx.toolUseID,
permissionResult: result,
permissionPromptStartTimeMs,
And the queue item carries the real callback surface:
onAbort() {
if (!claim()) return
...
resolveOnce(ctx.cancelAndAbort(undefined, true))
},
async onAllow(
updatedInput,
permissionUpdates: PermissionUpdate[],
feedback?: string,
contentBlocks?: ContentBlockParam[],
) {
if (!claim()) return
...
resolveOnce(
await ctx.handleUserAllow(
updatedInput,
permissionUpdates,
feedback,
permissionPromptStartTimeMs,
contentBlocks,
result.decisionReason,
),
)
},
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
if (!claim()) return
...
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') {
if (!claim()) return
...
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
}
},
})
}
This is why the interactive handler feels different from the other two. It
does not mean automation is finished. On the main-agent path,
handleInteractivePermission() opens the queue and still runs ctx.runHooks(...)
and executeAsyncClassifierCheck(...) in the background when
awaitAutomatedChecksBeforeDialog is false.
Those background checks race the user choice, bridgeCallbacks.onResponse(...),
and channelCallbacks.onResponse(...). The createResolveOnce(...) guard is
what makes the first winner final.
Takeaways
- The permission system revolves around a three-way result shape: allow, ask, or deny.
- useCanUseTool() first delegates to hasPermissionsToUseTool(), then decides whether coordinator, swarm, classifier, or interactive logic gets the next chance.
- createPermissionContext() is the shared workbench that keeps logging, persistence, abort handling, and result-building consistent across all permission paths.