Skip to main content
Every tool call passes through a five-stage authorization pipeline before execution. The pipeline is fail-closed: a tool that fails any stage is denied and the agent receives a tool_result error explaining why. No stage is skipped.

The permission pipeline

1

validateInput()

The first gate is the tool’s own validateInput() method. It checks that the input arguments are structurally valid and within the tool’s allowed scope — for example, that a file path is within the working directory, or that a shell command doesn’t contain disallowed patterns.A failed validateInput() returns { result: false, message, errorCode }. The error is sent back to the model as a tool_result so it can correct its approach. No permission dialog is shown to the user.validateInput() is optional on the Tool interface. Tools that omit it skip directly to the next stage.
2

PreToolUse hooks

User-defined shell commands configured in settings.json under the hooks.PreToolUse key run next. Each hook receives the tool name and its input as JSON and exits with one of three outcomes:
  • Approve (exit code 0, no output) — proceed to the next stage
  • Deny (exit code 0, output starting with DENY:) — block the call with the provided reason
  • Modify (exit code 0, JSON output with updatedInput) — replace the input before proceeding
Hooks run with a 60-second timeout. A hook that times out is treated as an approval.
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "my-audit-script.sh" }]
      }
    ]
  }
}
3

Permission rules

Three static rule sets are checked in order. Rules are stored in AppState.toolPermissionContext and sourced from settings.json, CLI arguments, and decisions made earlier in the session.
Rule setBehavior
alwaysAllowRulesIf the tool name and input match a pattern in this set, the call is approved automatically. No prompt is shown.
alwaysDenyRulesIf the tool name and input match a pattern in this set, the call is denied automatically. No prompt is shown.
alwaysAskRulesIf the tool name and input match a pattern in this set, the call always shows an interactive prompt — even if the user previously chose “Allow Always.”
Rule patterns support tool-name-level matching and, for tools that implement preparePermissionMatcher(), input-level matching (e.g., Bash(git *) to match only git commands).If no rule matches, execution falls through to the interactive prompt.
4

Interactive prompt

When no rule covers the current call, the user sees a permission dialog showing the tool name and its input. Three options are available:
ChoiceEffect
Allow OnceApprove this specific call. The next identical call will prompt again.
Allow AlwaysApprove all future calls to this tool with this pattern. Adds a rule to alwaysAllowRules for the session.
DenyBlock this call. The model receives a tool_result error.
In non-interactive sessions (SDK/headless mode, background agents), there is no terminal to show a dialog. The shouldAvoidPermissionPrompts flag in the permission context causes all interactive prompts to auto-deny instead.
5

checkPermissions()

The final stage is the tool’s own checkPermissions() method. This is called only after all previous stages have approved the call. It contains tool-specific authorization logic that is too detailed for general rules — for example, verifying that a file path falls within an allowed working directory, or that a network request targets an allowed host.The default implementation provided by buildTool() always approves: { behavior: 'allow', updatedInput: input }. Security-sensitive tools override this.

Permission context

The permission context is stored in AppState.toolPermissionContext and is an immutable (DeepImmutable) snapshot at the start of each turn:
// src/Tool.ts
export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource
  alwaysDenyRules: ToolPermissionRulesBySource
  alwaysAskRules: ToolPermissionRulesBySource
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  shouldAvoidPermissionPrompts?: boolean
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode
}>
Rules are keyed by source (e.g., 'cli', 'settings', 'session') so that CLI-provided rules and user-session decisions can be managed separately.

Permission modes

The mode field in ToolPermissionContext controls the overall permission posture:
ModeBehavior
'default'Normal operation. Rules and prompts apply.
'plan'Read-only mode. All writes are blocked. The agent can inspect but not change anything.
'bypassPermissions'All permission checks are skipped. Requires isBypassPermissionsModeAvailable: true.
'auto'Auto-approval mode via the security classifier. Requires isAutoModeAvailable: true.

Bypassing permissions

The --dangerously-skip-permissions CLI flag sets mode to 'bypassPermissions', skipping all interactive prompts. This flag is intended for fully automated pipelines where there is no user present and all risk is accepted by the caller.
node cli.js --dangerously-skip-permissions -p "Refactor all files in src/"
--dangerously-skip-permissions disables all permission gates including checkPermissions(). The agent can read, write, delete, and execute anything within reach. Only use this in sandboxed environments or automated pipelines where you fully trust the model’s actions.

PostToolUse hooks

A symmetric PostToolUse hook runs after a tool completes successfully. It receives the tool name, input, and output. Use it for audit logging, side-effects, or triggering downstream automation.
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "FileEditTool",
        "hooks": [{ "type": "command", "command": "git add -p" }]
      }
    ]
  }
}

Denial tracking

Claude Code tracks consecutive permission denials in DenialTrackingState. After enough denials accumulate (the threshold is configurable), the system falls back to prompting the user directly regardless of any cached alwaysAllow rules. This prevents a stuck loop where the model repeatedly tries a denied operation. For async sub-agents whose setAppState is a no-op, denial tracking is maintained in a mutable localDenialTracking field on the ToolUseContext so the counter still accumulates at the sub-agent level.

Build docs developers (and LLMs) love