The Tool System: How AI Safely Interacts with the Outside World

Deep dive into Claude Code's 40+ tool system — from type definitions to concurrent execution to the six-layer permission evaluation pipeline, understanding how AI safely operates on the external world

Overview

In the first two articles, we established a big-picture understanding of Claude Code's architecture and dove deep into the query engine's streaming loop. Now we arrive at the third critical layer of the architecture — the tool system.

If the query engine is Claude Code's "brain," then tools are its "hands and feet." Without tools, the AI can only generate text. With tools, the AI can read and write files, execute shell commands, search code, visit web pages, and even spawn sub-agents.

But this power comes with enormous risk. An AI that can execute rm -rf / without proper permission controls is a ticking time bomb. Claude Code's tool system must not only provide powerful capabilities, but also ensure safety before every execution.

This article will analyze the tool system from three dimensions:

  1. Definition — What are tools, and how are they declared?
  2. Execution — How are multiple tools executed concurrently and safely?
  3. Permissions — Who decides whether a tool is allowed to execute?

Tool Type Definition

Every tool is an object conforming to the Tool type. Let's understand this type field by field:

src/Tool.ts:362-599
TypeScript
362export type Tool<
363 Input extends AnyObject = AnyObject,
364 Output = unknown,
365 P extends ToolProgressData = ToolProgressData,
366> = {
367 // Identity
368 aliases?: string[] // Backward-compatible aliases
369 searchHint?: string // Keyword matching for deferred discovery
370
371 // Core methods
372 call(args, context, canUseTool, parentMessage, onProgress?)
373 : Promise<ToolResult<Output>>
374 description(input, options): Promise<string>
375
376 // Schema
377 readonly inputSchema: Input // Zod schema
378 readonly inputJSONSchema?: ToolInputJSONSchema // JSON Schema (for MCP tools)
379
380 // Behavior declarations
381 isConcurrencySafe(input): boolean // Can it run in parallel with other tools?
382 isEnabled(): boolean // Is it available in the current environment?
383 isReadOnly(input): boolean // Is it a read-only operation?
384 isDestructive?(input): boolean // Does it perform irreversible operations?
385 interruptBehavior?(): 'cancel' | 'block' // Behavior when user interrupts
386
387 // Permissions
388 checkPermissions(input, context): Promise<PermissionResult>
389 validateInput?(input, context): Promise<ValidationResult>
390 preparePermissionMatcher?(input): Promise<(pattern: string) => boolean>
391
392 // Output control
393 maxResultSizeChars: number // Maximum result character count
394 readonly name: string
395}

This type definition spans nearly 240 lines of src/Tool.ts (362-599), containing roughly 30 fields and methods. The code above is a simplified version showing the core parts — the complete type also includes UI rendering methods (renderToolResultMessage, userFacingName), analytics methods (toAutoClassifierInput), and more.

classDiagram
    class Tool {
        +name: string
        +aliases: string[]
        +searchHint: string
        +inputSchema: ZodType
        +inputJSONSchema?: JSONSchema
        +maxResultSizeChars: number
        +call(): Promise~ToolResult~
        +description(): Promise~string~
        +checkPermissions(): Promise~PermissionResult~
        +isConcurrencySafe(input): boolean
        +isEnabled(): boolean
        +isReadOnly(input): boolean
        +isDestructive?(input): boolean
        +interruptBehavior?(): cancel | block
        +validateInput?(): Promise~ValidationResult~
    }

    class ToolResult {
        +data: T
        +newMessages?: Message[]
        +contextModifier?: Function
        +mcpMeta?: Object
    }

    class ToolPermissionContext {
        +mode: PermissionMode
        +alwaysAllowRules: RulesBySource
        +alwaysDenyRules: RulesBySource
        +alwaysAskRules: RulesBySource
        +additionalWorkingDirectories: Map
        +isBypassPermissionsModeAvailable: boolean
        +awaitAutomatedChecksBeforeDialog?: boolean
        +shouldAvoidPermissionPrompts?: boolean
    }

    Tool --> ToolResult : returns
    Tool ..> ToolPermissionContext : checked against

Let's focus on a few designs that are particularly worth understanding.

Two Schema Formats: Zod vs JSON Schema

Notice the coexistence of inputSchema and inputJSONSchema. This isn't redundant design — tools from two different sources need different schema formats:

  • Built-in tools (BashTool, FileReadTool, etc.) use Zod schema — TypeScript-native, providing compile-time type checking and runtime validation
  • MCP tools (from external MCP servers) use JSON Schema — because the MCP protocol defines tool interfaces using JSON Schema
TypeScript
1// Zod schema example for built-in tools
2const inputSchema = z.object({
3 command: z.string().describe("The shell command to execute"),
4 timeout: z.number().optional().describe("Timeout in milliseconds"),
5 run_in_background: z.boolean().optional(),
6})
7
8// JSON Schema for MCP tools (dynamically fetched from MCP server)
9const inputJSONSchema: ToolInputJSONSchema = {
10 type: "object",
11 properties: {
12 query: { type: "string", description: "SQL query" }
13 },
14 required: ["query"]
15}

The ToolInputJSONSchema type is defined in src/Tool.ts:15-21, requiring the root type to be object:

TypeScript
1export type ToolInputJSONSchema = {
2 [x: string]: unknown
3 type: 'object'
4 properties?: {
5 [x: string]: unknown
6 }
7}

Zod schemas are automatically converted to JSON Schema format before Anthropic API calls — this conversion is transparent to tool developers. The dual-track approach lets built-in tools benefit from TypeScript's type system while allowing external MCP tools to work without introducing a Zod dependency.

Behavior Declarations: Tools Tell the System About Themselves

Claude Code's tool system adopts a "declarative" design — tools don't passively wait for the scheduler to query their properties, but proactively declare their behavioral characteristics. This lets the scheduler make correct scheduling decisions without understanding the tool's specific implementation.

isConcurrencySafe(input) — Can this tool be executed in parallel with others?

TypeScript
1// GrepTool is read-only, safe to run in parallel
2isConcurrencySafe() { return true }
3
4// FileWriteTool modifies the file system, cannot run in parallel
5isConcurrencySafe() { return false }
6
7// Note: the method receives an input parameter, allowing decisions based on specific input
8// Some tools may return different concurrency safety based on different inputs

interruptBehavior() — When a user submits a new message during tool execution, should this tool be immediately cancelled or allowed to complete?

  • 'cancel' — Abort immediately (default behavior for most tools)
  • 'block' — Continue running, new message waits (e.g., BashTool executing a git commit)

Note that this method doesn't take an input parameter — interrupt behavior is tool-level, not dependent on specific input.

isReadOnly(input) — Is this a read-only operation? Read-only tools typically receive more lenient treatment during permission evaluation.

isDestructive?(input) — Does it perform irreversible operations (delete, overwrite, send)? This method is optional, defaulting to false. It provides an additional risk signal to the permission system.

isEnabled() — Is the tool available in the current environment? The getToolsForDefaultPreset() function in src/tools.ts filters out tools where isEnabled() returns false.

ToolResult: The Tool's Return Type

What a tool returns after execution is not raw data, but a structured ToolResult<T>:

src/Tool.ts:321-336
TypeScript
321export type ToolResult<T> = {
322 data: T // The tool's actual output
323 newMessages?: ( // Messages to inject into conversation history
324 | UserMessage
325 | AssistantMessage
326 | AttachmentMessage
327 | SystemMessage
328 )[]
329 contextModifier?: (context: ToolUseContext) => ToolUseContext // Modify subsequent context
330 mcpMeta?: { // MCP protocol metadata passthrough
331 _meta?: Record<string, unknown>
332 structuredContent?: Record<string, unknown>
333 }
334}

The contextModifier field is particularly interesting — it allows a tool to modify the execution context for subsequent tools. But there's an important restriction: only non-concurrency-safe tools have their context modifiers applied. This is because the execution order of concurrent tools is non-deterministic; if they all tried to modify the context, the results would be unpredictable.


Tool Registration and Discovery

Static Registration

All built-in tools are registered in src/tools.ts. The getAllBaseTools() function (line 193) is the source of truth for tools:

src/tools.ts:193-251
TypeScript
193export function getAllBaseTools(): Tools {
194 return [
195 AgentTool,
196 TaskOutputTool,
197 BashTool,
198 // Skip Glob/Grep when embedded search tools are available
199 ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
200 ExitPlanModeV2Tool,
201 FileReadTool,
202 FileEditTool,
203 FileWriteTool,
204 NotebookEditTool,
205 WebFetchTool,
206 TodoWriteTool,
207 WebSearchTool,
208 // ... more tools
209 ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
210 ]
211}

This function demonstrates several tool registration patterns:

1. Compile-Time Feature Flags

src/tools.ts:29-35
TypeScript
29const cronTools = feature('AGENT_TRIGGERS')
30 ? [
31 require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
32 require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
33 require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
34 ]
35 : []

feature() is a Bun compile-time macro — when AGENT_TRIGGERS is false at build time, the entire require() branch and its transitive dependencies are removed from the final output.

2. Lazy require to Break Circular Dependencies

src/tools.ts:63-72
TypeScript
63const getTeamCreateTool = () =>
64 require('./tools/TeamCreateTool/TeamCreateTool.js')
65 .TeamCreateTool as typeof import('./tools/TeamCreateTool/TeamCreateTool.js').TeamCreateTool
66const getTeamDeleteTool = () =>
67 require('./tools/TeamDeleteTool/TeamDeleteTool.js')
68 .TeamDeleteTool

Note that getTeamCreateTool() uses a lazy require() instead of a top-level import. This is to break circular dependencies — TeamCreateTool's implementation may depend on types or constants exported by tools.ts.

3. Environment Variable Conditional Registration

src/tools.ts:16-19
TypeScript
16const REPLTool =
17 process.env.USER_TYPE === 'ant'
18 ? require('./tools/REPLTool/REPLTool.js').REPLTool
19 : null

Some tools are only available to specific user types (e.g., Anthropic internal users).

Tool Pool Assembly: assembleToolPool

getAllBaseTools() is just the first step. The actual tool list exposed to the AI still needs to go through filtering and merging. assembleToolPool() (line 345) is the unified entry point for assembling the final tool pool:

src/tools.ts:345-367
TypeScript
345export function assembleToolPool(
346 permissionContext: ToolPermissionContext,
347 mcpTools: Tools,
348): Tools {
349 const builtInTools = getTools(permissionContext)
350 const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
351 // Sort to ensure prompt cache stability
352 const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
353 return uniqBy(
354 [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
355 'name',
356 )
357}

This function does three things:

  1. Gets built-in tools — filters out disabled tools and those blocked by deny rules via getTools()
  2. Filters MCP tools — applies the same deny rules
  3. Deduplicates and merges — built-in tools take priority, sorted by name for prompt cache stability

The purpose of sorting isn't just tidiness — the comments explain that the Anthropic API server places a cache breakpoint at the last position of built-in tools. If MCP tools are sorted into the middle of built-in tools, all downstream cache keys are invalidated.

Deferred Discovery: ToolSearchTool

When there are too many tools (40+), putting all tool definitions into the System Prompt consumes a lot of tokens. Claude Code's solution is deferred loading:

...

Each tool can control its deferred loading behavior via two fields:

  • shouldDefer — Tools set to true are deferred when the tool count exceeds the threshold
  • alwaysLoad — Tools set to true are never deferred and always appear in the initial prompt

ToolSearchTool supports three query modes:

  1. Exact select: "select:Read,Edit,Grep" — fetch specific tools by name
  2. Keyword search: "notebook jupyter" — fuzzy matching, assisted by the searchHint field
  3. Prefix+keyword: "+slack send" — require name to contain "slack", then rank by remaining terms

Each tool's searchHint field provides keyword matching support:

TypeScript
1// Tool searchHint examples
2// AgentTool
3searchHint: "subagent parallel background isolation worktree"
4
5// NotebookEditTool
6searchHint: "jupyter ipynb cell"
7
8// When the AI searches for "parallel", AgentTool will be matched
9// searchHint description says: "3-10 words, no trailing period.
10// Prefer terms not already in the tool name"

MCP Dynamic Tools

Beyond built-in tools and deferred discovery, tools can also come from external MCP (Model Context Protocol) servers. When an MCP server connects, all tools it exposes are dynamically registered to the tool pool. MCP tools have a special mcpInfo field:

src/Tool.ts:451-455
TypeScript
451mcpInfo?: { serverName: string; toolName: string }

MCP tool names are typically prefixed as mcp__serverName__toolName, while mcpInfo preserves the original, unprefixed server name and tool name. This lets the permission system configure rules at the MCP server level (e.g., mcp__database matches all tools from that server).

MCP tools can also mark themselves as alwaysLoad via _meta['anthropic/alwaysLoad'], ensuring they remain always visible even in deferred loading mode.


StreamingToolExecutor: Concurrency-Safe Tool Orchestration

When the API response contains multiple tool_use calls, StreamingToolExecutor (src/services/tools/StreamingToolExecutor.ts) is responsible for deciding how to execute them — in parallel or serially.

TrackedTool: A Tool's Runtime State

src/services/tools/StreamingToolExecutor.ts:19-33
TypeScript
19type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
20
21type TrackedTool = {
22 id: string
23 block: ToolUseBlock
24 assistantMessage: AssistantMessage
25 status: ToolStatus
26 isConcurrencySafe: boolean
27 promise?: Promise<void>
28 results?: Message[]
29 pendingProgress: Message[] // Progress message buffer
30 contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>
31}

Each tool in the executor goes through four states: queued (waiting in queue) -> executing (running) -> completed (finished, results collected) -> yielded (results emitted upstream).

Concurrency Scheduling Model

sequenceDiagram
    participant API as API Response
    participant STE as StreamingToolExecutor
    participant T1 as GrepTool (concurrency-safe)
    participant T2 as FileReadTool (concurrency-safe)
    participant T3 as BashTool (not concurrency-safe)
    participant T4 as FileWriteTool (not concurrency-safe)

    API->>STE: tool_use[1]: grep "login"
    API->>STE: tool_use[2]: read "auth.ts"
    API->>STE: tool_use[3]: bash "npm test"
    API->>STE: tool_use[4]: write "fix.ts"

    Note over STE: Classify: [1,2] concurrency-safe, [3,4] not concurrency-safe

    par Execute concurrency-safe tools in parallel
        STE->>T1: Execute grep
        STE->>T2: Execute read
    end
    T1-->>STE: Result 1
    T2-->>STE: Result 2

    Note over STE: After parallel tools complete, execute non-concurrency-safe tools serially
    STE->>T3: Execute bash
    T3-->>STE: Result 3
    STE->>T4: Execute write
    T4-->>STE: Result 4

    STE-->>API: [Result 1, 2, 3, 4] (original order preserved)

The scheduling logic is implemented in the canExecuteTool() method (line 129):

src/services/tools/StreamingToolExecutor.ts:129-135
TypeScript
129private canExecuteTool(isConcurrencySafe: boolean): boolean {
130 const executingTools = this.tools.filter(t => t.status === 'executing')
131 return (
132 executingTools.length === 0 ||
133 (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
134 )
135}

The rule is remarkably concise:

  • If no tools are executing -> any tool can execute
  • If tools are executing, and both the current tool and all executing tools are concurrency-safe -> can execute
  • Otherwise -> queue and wait

The processQueue() method (line 140) is called when a tool is enqueued or a tool completes. It traverses the queue and starts all tools that can be executed. For non-concurrency-safe tools, it stops traversal as soon as it encounters one that can't execute — guaranteeing ordering.

Tool Enqueuing: addTool

When a tool_use block appears in the streaming API response, the addTool() method (line 76) is called:

src/services/tools/StreamingToolExecutor.ts:76-124
TypeScript
76addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
77 const toolDefinition = findToolByName(this.toolDefinitions, block.name)
78 if (!toolDefinition) {
79 // Unknown tool: immediately mark as completed with an error result
80 this.tools.push({
81 id: block.id, block, assistantMessage,
82 status: 'completed',
83 isConcurrencySafe: true,
84 pendingProgress: [],
85 results: [/* error: "No such tool available" */],
86 })
87 return
88 }
89
90 // Parse input and determine concurrency safety
91 const parsedInput = toolDefinition.inputSchema.safeParse(block.input)
92 const isConcurrencySafe = parsedInput?.success
93 ? (() => {
94 try { return Boolean(toolDefinition.isConcurrencySafe(parsedInput.data)) }
95 catch { return false }
96 })()
97 : false
98
99 this.tools.push({
100 id: block.id, block, assistantMessage,
101 status: 'queued', isConcurrencySafe,
102 pendingProgress: [],
103 })
104
105 void this.processQueue()
106}

Note the concurrency safety determination: if input parsing fails (safeParse returns success: false), the tool is treated as non-concurrency-safe. This is a safe default — if the input can't even be parsed, serial execution is safer.

Sibling Abort: Cascading Cancellation

When a Bash tool execution fails, other tools running in parallel are cancelled. But there's an important detail here — only BashTool errors trigger cascading cancellation:

src/services/tools/StreamingToolExecutor.ts:354-363
TypeScript
354if (isErrorResult) {
355 thisToolErrored = true
356 // Only Bash errors cancel siblings. Bash commands often have implicit
357 // dependency chains (e.g. mkdir fails -> subsequent commands pointless).
358 // Read/WebFetch/etc are independent — one failure shouldn't nuke the rest.
359 if (tool.block.name === BASH_TOOL_NAME) {
360 this.hasErrored = true
361 this.erroredToolDescription = this.getToolDescription(tool)
362 this.siblingAbortController.abort('sibling_error')
363 }
364}

Why only Bash? Because Bash commands often have implicit dependency chains — if mkdir fails, a subsequent cd && make is pointless. But FileReadTool or WebFetchTool failures are typically independent — one file read failure shouldn't cancel other file reads.

siblingAbortController is a child controller of toolUseContext.abortController (line 59):

src/services/tools/StreamingToolExecutor.ts:53-61
TypeScript
53constructor(
54 private readonly toolDefinitions: Tools,
55 private readonly canUseTool: CanUseToolFn,
56 toolUseContext: ToolUseContext,
57) {
58 this.toolUseContext = toolUseContext
59 this.siblingAbortController = createChildAbortController(
60 toolUseContext.abortController,
61 )
62}

Aborting siblingAbortController does not abort the parent toolUseContext.abortController — the query loop doesn't end the entire turn because of a single Bash error. Cancelled sibling tools receive a synthetic error message:

src/services/tools/StreamingToolExecutor.ts:153-205
TypeScript
153private createSyntheticErrorMessage(
154 toolUseId: string,
155 reason: 'sibling_error' | 'user_interrupted' | 'streaming_fallback',
156 assistantMessage: AssistantMessage,
157): Message {
158 if (reason === 'user_interrupted') {
159 // User interrupt: return REJECT_MESSAGE
160 }
161 if (reason === 'streaming_fallback') {
162 // Streaming fallback: return fallback error
163 }
164 // sibling_error: return "Cancelled: parallel tool call X errored"
165 const desc = this.erroredToolDescription
166 const msg = desc
167 ? `Cancelled: parallel tool call ${desc} errored`
168 : 'Cancelled: parallel tool call errored'
169 // ...
170}

Progress Buffering and Ordered Emission

During tool execution, progress messages are emitted (e.g., search progress, file read progress, subprocess output). The handling strategy for these messages is:

  • Progress messages -> yield immediately, displayed to the user in real time
  • Result messages -> yield in original tool_use order, ensuring conversation history consistency

This separation is implemented in the getCompletedResults() method (line 412):

src/services/tools/StreamingToolExecutor.ts:412-440
TypeScript
412*getCompletedResults(): Generator<MessageUpdate, void> {
413 for (const tool of this.tools) {
414 // Always yield progress messages immediately, regardless of tool status
415 while (tool.pendingProgress.length > 0) {
416 const progressMessage = tool.pendingProgress.shift()!
417 yield { message: progressMessage, newContext: this.toolUseContext }
418 }
419
420 if (tool.status === 'yielded') continue
421
422 if (tool.status === 'completed' && tool.results) {
423 tool.status = 'yielded'
424 for (const message of tool.results) {
425 yield { message, newContext: this.toolUseContext }
426 }
427 } else if (tool.status === 'executing' && !tool.isConcurrencySafe) {
428 break // Non-concurrent tools must maintain order
429 }
430 }
431}

Note the break at the end — when encountering a non-concurrency-safe tool that is still executing, it stops emitting results from subsequent tools. This guarantees that non-concurrent tool results appear in strict order in the conversation history.

In getRemainingResults() (line 453), the executor uses Promise.race to simultaneously wait for tool completion and progress messages:

src/services/tools/StreamingToolExecutor.ts:466-484
TypeScript
466// Wait for any tool to complete or progress messages to become available
467const progressPromise = new Promise<void>(resolve => {
468 this.progressAvailableResolve = resolve
469})
470await Promise.race([...executingPromises, progressPromise])

When a tool's executeTool() method receives a progress message, it pushes the message into the pendingProgress array and triggers progressAvailableResolve, waking up the waiting getRemainingResults().

Discard: Cleanup During Streaming Fallback

StreamingToolExecutor also has a discard() method (line 69) for streaming fallback scenarios:

TypeScript
1discard(): void {
2 this.discarded = true
3}

When API streaming fails and needs to fall back to non-streaming mode, the results from tools that already started executing need to be discarded. After setting discarded = true, getCompletedResults() and getRemainingResults() return immediately without emitting any results. Queued tools also won't start.


Permission System: Six Layers of Security

The most critical design of the tool system is not capability, but constraint. Claude Code implements a six-layer permission evaluation pipeline that every tool call must pass through before execution.

ToolPermissionContext: The Immutable Permission Context

src/Tool.ts:123-138
TypeScript
123export type ToolPermissionContext = DeepImmutable<{
124 mode: PermissionMode // 'default' | 'plan' | 'auto' | 'bypassPermissions'
125
126 // Three rule sets, each grouped by source
127 alwaysAllowRules: ToolPermissionRulesBySource // Auto-allow
128 alwaysDenyRules: ToolPermissionRulesBySource // Auto-deny
129 alwaysAskRules: ToolPermissionRulesBySource // Always ask the user
130
131 // File system scope
132 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
133
134 // Advanced options
135 isBypassPermissionsModeAvailable: boolean
136 isAutoModeAvailable?: boolean
137 shouldAvoidPermissionPrompts?: boolean // Background agents don't show dialogs
138 awaitAutomatedChecksBeforeDialog?: boolean // Coordinator worker
139 prePlanMode?: PermissionMode // Restore after exiting plan mode
140}>

The DeepImmutable<> wrapper is the heart of this design. The DeepImmutable type (from src/types/utils.ts) recursively marks all properties as readonly, including nested objects and Maps. This means the permission context cannot be modified once created.

Why is immutability so important? Because permission decisions must be based on consistent state. Consider a race condition:

  1. Thread A reads alwaysDenyRules, finds no match
  2. Thread B adds a new deny rule
  3. Thread A continues executing, allowing an operation that should have been denied based on stale rules

DeepImmutable prevents step 2 from happening at compile time — any code attempting to modify the permission context will produce a TypeScript compilation error. When permissions need to be updated, an entirely new ToolPermissionContext object must be created.

getEmptyToolPermissionContext() (line 140) provides a default empty permission context:

src/Tool.ts:140-148
TypeScript
140export const getEmptyToolPermissionContext: () => ToolPermissionContext =
141 () => ({
142 mode: 'default',
143 additionalWorkingDirectories: new Map(),
144 alwaysAllowRules: {},
145 alwaysDenyRules: {},
146 alwaysAskRules: {},
147 isBypassPermissionsModeAvailable: false,
148 })

Rule Source Tracking

Rules don't just have "allow" and "deny" — they also have a source. ToolPermissionRulesBySource records where each rule comes from:

  • User rules — Configured by the user in ~/.claude/settings.json
  • Project rules — In the project's .claude/settings.json
  • Policy rules — Distributed by organization admins via MDM/remote configuration

The purpose of source tracking isn't just auditing — it's priority resolution: policy rules take precedence over project rules, which take precedence over user rules. When rules conflict, the higher-priority source "wins."

The filterToolsByDenyRules() function (line 262) filters out globally denied tools at the tool pool assembly stage:

src/tools.ts:262-269
TypeScript
262export function filterToolsByDenyRules<
263 T extends { name: string; mcpInfo?: { serverName: string; toolName: string } },
264>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
265 return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
266}

Note the generic constraint — it supports both built-in tools (with only name) and MCP tools (with mcpInfo), allowing the same filtering logic to apply to both tool sources.

Six-Layer Evaluation Pipeline

...

Each layer in detail:

Layer 1 — Hook Pre-check: If PreToolUse hooks are configured (shell commands defined by the user in settings.json), the hook is executed first. Hooks can directly allow (behavior: 'allow') or deny (behavior: 'deny') the tool call, and can also modify tool input via updatedInput. The runHooks() method (line 216) in PermissionContext.ts implements this logic.

Layer 2 — Automated Classifier (BASH_CLASSIFIER): Specific to BashTool, uses a classifier to automatically determine whether a command is safe. The tryClassifier() method (line 176) only exists when the BASH_CLASSIFIER feature flag is enabled. After the classifier passes, approval information is recorded for the TRANSCRIPT_CLASSIFIER feature.

Layer 3 — alwaysDeny Rules: If the tool call matches any "always deny" rule, it is denied outright. Not overridable.

Layer 4 — alwaysAllow Rules: If the tool call matches an "always allow" rule, it is allowed through.

Layer 5 — Permission Mode Check: Decides whether user confirmation is needed based on the current PermissionMode.

Layer 6 — Interactive Dialog: Displays a terminal dialog for the user to decide. The user can choose "Allow," "Deny," or "Always allow this type of operation." When "Always allow" is selected, the system persists the rule to the configuration file via the persistPermissions() method (line 139).

PermissionContext: The Core of Permission Handling

The createPermissionContext() function (line 96) in src/hooks/toolPermission/PermissionContext.ts creates the core context for permission handling. It provides the following key capabilities:

src/hooks/toolPermission/PermissionContext.ts:96-104
TypeScript
96function createPermissionContext(
97 tool: ToolType,
98 input: Record<string, unknown>,
99 toolUseContext: ToolUseContext,
100 assistantMessage: AssistantMessage,
101 toolUseID: string,
102 setToolPermissionContext: (context: ToolPermissionContext) => void,
103 queueOps?: PermissionQueueOps,
104)

This context object contains several helper methods:

  • logDecision() — Records the permission decision to the analytics system
  • logCancelled() — Records tool cancellation events
  • persistPermissions() — Persists the user's permission choice to the configuration file
  • resolveIfAborted() — Checks whether execution has been cancelled (e.g., user pressed Ctrl+C)
  • cancelAndAbort() — Denies the tool and aborts execution
  • runHooks() — Executes PreToolUse hooks
  • tryClassifier() — Attempts the automated classifier (conditionally present)
  • buildAllow() / buildDeny() — Constructs allow/deny decision objects
  • handleUserAllow() — Handles user approval (may include permission persistence)

Note the subtle logic in the cancelAndAbort() method (line 154) — it chooses different rejection messages depending on whether this is a subagent:

TypeScript
1cancelAndAbort(feedback?, isAbort?, contentBlocks?): PermissionDecision {
2 const sub = !!toolUseContext.agentId
3 const baseMessage = feedback
4 ? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}`
5 : sub ? SUBAGENT_REJECT_MESSAGE : REJECT_MESSAGE
6 // Subagents don't abort the parent
7 if (isAbort || (!feedback && !contentBlocks?.length && !sub)) {
8 toolUseContext.abortController.abort()
9 }
10 return { behavior: 'ask', message, contentBlocks }
11}

Three Permission Handlers

The actual execution of permission evaluation is done by one of three handlers, depending on the current running mode:

Text
1src/hooks/toolPermission/handlers/
2├── interactiveHandler.ts // Interactive mode: show dialog for user to decide
3├── coordinatorHandler.ts // Coordinator mode: automated classification + optional user confirmation
4└── swarmWorkerHandler.ts // Swarm Worker mode: delegate to the dispatcher
  • interactiveHandler — The most common handler. When a tool needs user approval, it renders a terminal dialog showing the tool name, parameters, risk level, and lets the user choose "Allow," "Deny," or "Always allow this type of operation."
  • coordinatorHandler — In multi-agent mode, Worker tool calls first go through automated classification; only calls the classifier can't determine are escalated to the user. The awaitAutomatedChecksBeforeDialog flag controls whether to wait for classification results before showing the user dialog.
  • swarmWorkerHandler — Workers in a Swarm delegate permission decisions to the Coordinator, making no judgment themselves.

PermissionQueueOps interface (lines 57-61) decouples the permission UI from permission logic:

TypeScript
1type PermissionQueueOps = {
2 push(item: ToolUseConfirm): void
3 remove(toolUseID: string): void
4 update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
5}

In REPL mode, these operations are backed by React state; in SDK mode, the implementation may be entirely different.

ResolveOnce: Race-Safe Decision Resolution

The permission system faces a classic race condition: the user presses "Allow" at the same time an abort signal arrives. If both callbacks try to resolve the same Promise, the behavior becomes unpredictable.

createResolveOnce<T>() (lines 75-93) provides atomic-level race protection:

src/hooks/toolPermission/PermissionContext.ts:75-93
TypeScript
75function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
76 let claimed = false
77 let delivered = false
78 return {
79 resolve(value: T) {
80 if (delivered) return
81 delivered = true
82 claimed = true
83 resolve(value)
84 },
85 isResolved() { return claimed },
86 claim() {
87 if (claimed) return false
88 claimed = true
89 return true
90 },
91 }
92}

The claim() method provides a "claim first, execute later" pattern — in async callbacks, first call claim() to acquire exclusive rights, then perform operations that may have side effects. This closes the race window between the isResolved() check and the resolve() call.

Permission Mode Comparison

ModeBehaviorTypical Scenario
defaultDangerous operations ask the user, safe operations auto-allowNormal interactive use
planOnly read-only operations allowed, all modifications blockedPlanning phase, no execution
autoMost operations auto-allowed, high-risk ones still promptBatch automation tasks
bypassPermissionsAll operations auto-allowedTrusted automation environments

The prePlanMode field records the mode before entering plan mode, so it can be restored upon exiting plan mode.


Complete Tool Execution Flow

Integrating all the layers above, here is the complete flow from when a tool is called by the AI to when execution completes:

sequenceDiagram
    participant AI as LLM Response
    participant Q as query() loop
    participant STE as StreamingToolExecutor
    participant Perm as Permission System
    participant Tool as Concrete Tool

    AI->>Q: tool_use: { name: "Bash", input: { command: "npm test" } }
    Q->>STE: addTool(toolUseBlock, assistantMessage)

    STE->>STE: findToolByName() look up tool definition
    STE->>STE: inputSchema.safeParse() + isConcurrencySafe()

    Note over STE: canExecuteTool() checks concurrency conditions

    STE->>Perm: runToolUse() -> canUseTool()
    Perm->>Perm: Layer 1: runHooks()
    Perm->>Perm: Layer 2: tryClassifier()
    Perm->>Perm: Layer 3-4: deny/allow rule matching
    Perm->>Perm: Layer 5: Permission mode check
    alt Requires user confirmation
        Perm->>Perm: Layer 6: queueOps.push() show dialog
    end

    alt Allow
        Perm-->>STE: PermissionAllowDecision
        STE->>Tool: call(args, context, canUseTool, parentMessage, onProgress)
        Tool->>Tool: Execute actual operation
        loop Progress updates
            Tool-->>STE: onProgress(progressMessage)
            STE-->>Q: Yield progress message immediately
        end
        Tool-->>STE: ToolResult { data, newMessages? }
        STE-->>Q: Yield result message (in order)
    else Deny
        Perm-->>STE: PermissionDenyDecision
        STE-->>Q: Yield denial message
        Q->>AI: Inform AI the tool was denied
    end

Tool Output Management

A tool's output after execution is not sent directly to the AI — it goes through truncation and formatting.

Token Budget and maxResultSizeChars

Each tool declares maxResultSizeChars, controlling the maximum character count of its output. This value varies by tool:

TypeScript
1// BashTool: maxResultSizeChars = 100_000
2// GrepTool: dynamically calculated based on head_limit
3// FileReadTool: maxResultSizeChars = Infinity (special case)

FileReadTool sets maxResultSizeChars to Infinity — the source code comment explains why:

Set to Infinity for tools whose output must never be persisted (e.g. Read, where persisting creates a circular Read->file->Read loop and the tool already self-bounds via its own limits).

If Read's output were persisted to a disk file, the AI might use Read to read that file again, forming an infinite loop. Read already controls its output size through its own offset/limit parameters and doesn't need external truncation.

Output exceeding the maxResultSizeChars limit is saved to a temporary file, and the AI receives a preview plus a file path — not the full content. ContentReplacementState (src/utils/toolResultStorage.ts) manages this persistence process.

Large File Reading Strategy

FileReadTool provides pagination parameters for large files:

  • offset — Start reading from line N
  • limit — Read N lines
  • pages — Page range for PDF files (e.g., "1-5")

This lets the AI read specific sections of a file on demand, rather than loading the entire large file into context.

Output Mapping: From Tool Result to API Format

Every tool must implement mapToolResultToToolResultBlockParam(), converting its output to the Anthropic API's ToolResultBlockParam format:

TypeScript
1mapToolResultToToolResultBlockParam(
2 content: Output,
3 toolUseID: string,
4): ToolResultBlockParam

This method is responsible for serializing tool-specific data formats (such as file content, search results, command output) into text or image blocks that the API can understand.


Overview of All 45 Tools by Category

File Operations
FileReadTool
Multi-format reading
FileWriteTool
Full file write
FileEditTool
Precise replacement
GlobTool
Pattern matching
GrepTool
Content search
Execution
BashTool
Shell commands
PowerShellTool
Windows
NotebookEditTool
Jupyter
Agent
AgentTool
Sub-agents
SendMessageTool
Agent communication
TeamCreateTool
Team mode
Extension
MCP Tools
MCP Protocol
SkillTool
Skill execution
ToolSearchTool
Deferred discovery
Web
WebFetchTool
URL fetching
WebSearchTool
Search
State & Mode
TaskCreateTool
Task management
EnterPlanModeTool
Plan mode
EnterWorktreeTool
Worktree isolation
Automation
CronCreateTool
Scheduled tasks
RemoteTriggerTool
Remote triggers
SleepTool
Proactive waiting

The specific design of each tool will be covered in depth in dedicated articles. Article 21 will analyze the file operation trio, Article 22 will dive into BashTool, and Article 08 will explore multi-agent orchestration.


ToolUseContext: The Tool's Runtime Environment

Every tool receives a ToolUseContext object (src/Tool.ts:158-300) when executed, containing all the runtime information the tool needs. This type has 40+ fields and is one of the largest context types in Claude Code.

Core fields are organized into several categories:

Configuration and Options:

TypeScript
1options: {
2 commands: Command[] // Available command list
3 tools: Tools // Available tool list
4 mainLoopModel: string // Currently active model
5 mcpClients: MCPServerConnection[] // MCP connections
6 thinkingConfig: ThinkingConfig // Thinking mode configuration
7 isNonInteractiveSession: boolean // Whether in non-interactive mode
8 maxBudgetUsd?: number // Budget limit
9 refreshTools?: () => Tools // Dynamically refresh tool list
10}

State Management:

TypeScript
1getAppState(): AppState // Read global state
2setAppState(f: (prev) => AppState) // Update global state
3messages: Message[] // Current conversation history
4readFileState: FileStateCache // File cache

Abort and Control:

TypeScript
1abortController: AbortController // Abort signal
2setInProgressToolUseIDs: (f) => void // Track tools in progress
3setHasInterruptibleToolInProgress?: (v: boolean) => void

Sub-Agent Support:

TypeScript
1agentId?: AgentId // Sub-agent identifier
2agentType?: string // Agent type name
3queryTracking?: QueryChainTracking // Query chain tracking

The setAppStateForTasks field (line 186) deserves special attention — it's an "always-effective" state updater designed specifically for background tasks. The normal setAppState is a no-op in async subagents (to avoid concurrent state conflicts), but infrastructure operations (like registering/cleaning up background tasks) need a channel that always reaches the root store.


Transferable Engineering Patterns

1. Declarative Tool Behavior Design

Having tools self-declare their behavioral characteristics through methods like isConcurrencySafe(), interruptBehavior(), and isReadOnly(), rather than the scheduler hard-coding each tool's behavior. This lets new tools seamlessly plug into the scheduling system without modifying the scheduler code. StreamingToolExecutor's canExecuteTool() method is only 6 lines, yet it correctly handles any number of tool combinations — because the scheduling logic is entirely based on tools' self-declarations.

2. Dual-Track Schema

When a system needs to support both internal definitions and external protocols simultaneously, providing two schema formats (Zod + JSON Schema) is a pragmatic choice. The key is unifying the processing logic at runtime — safeParse() is used for built-in tool input validation, while MCP tools' JSON Schema is used directly during API serialization. Callers don't need to care about the schema's source format.

3. Layered Permissions with Source Tracking

The approach of recording rule sources (user/project/policy) in the permission system is worth emulating. It not only makes conflict resolution traceable, but also enables audit trails — administrators can see which permissions come from organizational policy and which were configured by users. filterToolsByDenyRules() filters out unavailable tools at the tool pool assembly stage, preventing the AI from wasting tokens trying to call a tool that will always be denied.

4. Immutable Permission Context

The DeepImmutable<ToolPermissionContext> design ensures consistency in permission evaluation. In any system requiring security-critical decisions, the immutability of the decision context is an effective defense against TOCTOU (Time-of-check to time-of-use) vulnerabilities. Creating new objects rather than modifying existing ones when updating permissions is a pattern also widely used in React's state management.

5. Selective Propagation of Cascading Cancellation

In StreamingToolExecutor, only BashTool errors trigger sibling abort, not errors from all tools. This "selective cascade" is more precise than "cancel all" or "cancel none" — it makes judgments based on domain knowledge about inter-tool dependencies. This pattern can be generalized to any scenario requiring concurrent cancellation.

6. Race-Safe Decision Resolution

The ResolveOnce claim() pattern provides an elegant solution for async race conditions. In permission dialogs, user actions and system cancellations can arrive simultaneously — claim() ensures only one callback "wins" the decision authority, avoiding duplicate resolves and unpredictable side effects.


Series Recap and What's Ahead

With this, the three foundational architecture articles have fulfilled their mission:

  1. Article 01 established the big-picture view of the 5-layer architecture
  2. Article 02 dove deep into the engine layer's streaming query loop
  3. Article 03 (this one) analyzed the tool layer's definitions, execution, and permissions

Starting from the next article, we move into independent deep-dive topics. You can skip around based on interest: