Studio Notebook

Claude Code Atlas

Tool Permission Flow

Learn how `useCanUseTool()` turns a proposed tool call into a real allow, ask, or deny path.

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:

  1. hasPermissionsToUseTool(...) returns a decision object whose behavior is allow, ask, or deny.
  2. useCanUseTool() reads that decision and decides whether the request is already done or needs more handling.
  3. createPermissionContext(...) builds the shared helper object for logging, persistence, queue updates, and abort handling.
  4. 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:

  1. useCanUseTool() does not do the primary permission check itself. It asks hasPermissionsToUseTool(...) for a PermissionDecision.
  2. A plain allow result short-circuits the rest of the flow. The hook logs it and resolves with ctx.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:

  1. deny resolves immediately after logging, and can also trigger the auto-mode denial notification path.
  2. ask does 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:

  1. resolveIfAborted(...) prevents stale permission work from continuing after cancellation.
  2. cancelAndAbort(...) builds the rejection message and may abort the controller.
  3. The returned object keeps the original tool, input, toolUseContext, assistantMessage, and toolUseID together 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.